From 0caf0b919825d5059c2c302c3dbca85a41c4a69d Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Tue, 2 Jun 2026 23:57:24 +0200 Subject: [PATCH 1/3] feat(gc): add WeakRef and FinalizationRegistry Implement weak targets, kept objects, finalization cleanup jobs, and error surfacing for queueMicrotask/finalizers. Add JavaScript coverage across interpreter and bytecode behavior plus documentation updates. --- docs/built-ins.md | 33 +- docs/garbage-collector.md | 28 +- docs/interpreter.md | 4 +- docs/language-tables.md | 1 + docs/language.md | 14 +- docs/value-system.md | 10 +- source/app/GocciaTestRunner.dpr | 6 +- .../units/Goccia.Builtins.GlobalPromise.pas | 6 + source/units/Goccia.Builtins.Globals.pas | 8 +- .../Goccia.Constants.ConstructorNames.pas | 2 + source/units/Goccia.Engine.pas | 64 +++- source/units/Goccia.Error.Messages.pas | 5 + source/units/Goccia.Error.Suggestions.pas | 3 + source/units/Goccia.FetchManager.pas | 40 ++- source/units/Goccia.GarbageCollector.pas | 282 +++++++++++------- source/units/Goccia.MicrotaskQueue.pas | 260 +++++++++++----- source/units/Goccia.Threading.pas | 9 +- source/units/Goccia.Values.ClassValue.pas | 36 +++ ...occia.Values.FinalizationRegistryValue.pas | 276 +++++++++++++++++ source/units/Goccia.Values.NativeFunction.pas | 11 + source/units/Goccia.Values.PromiseValue.pas | 2 + source/units/Goccia.Values.WeakRefValue.pas | 167 +++++++++++ .../FinalizationRegistry/constructor.js | 56 ++++ tests/built-ins/FinalizationRegistry/gc.js | 185 ++++++++++++ .../prototype/register.js | 68 +++++ .../prototype/toStringTag.js | 20 ++ .../prototype/unregister.js | 57 ++++ .../FinalizationRegistry/subclassing.js | 23 ++ tests/built-ins/Object/prototype/toString.js | 13 + tests/built-ins/WeakRef/constructor.js | 53 ++++ tests/built-ins/WeakRef/gc.js | 50 ++++ tests/built-ins/WeakRef/prototype/deref.js | 23 ++ .../WeakRef/prototype/toStringTag.js | 16 + tests/built-ins/WeakRef/subclassing.js | 26 ++ .../global-properties/global-this.js | 16 + .../built-ins/structuredClone/collections.js | 24 ++ .../language/classes/class-length-property.js | 2 + 37 files changed, 1660 insertions(+), 239 deletions(-) create mode 100644 source/units/Goccia.Values.FinalizationRegistryValue.pas create mode 100644 source/units/Goccia.Values.WeakRefValue.pas create mode 100644 tests/built-ins/FinalizationRegistry/constructor.js create mode 100644 tests/built-ins/FinalizationRegistry/gc.js create mode 100644 tests/built-ins/FinalizationRegistry/prototype/register.js create mode 100644 tests/built-ins/FinalizationRegistry/prototype/toStringTag.js create mode 100644 tests/built-ins/FinalizationRegistry/prototype/unregister.js create mode 100644 tests/built-ins/FinalizationRegistry/subclassing.js create mode 100644 tests/built-ins/WeakRef/constructor.js create mode 100644 tests/built-ins/WeakRef/gc.js create mode 100644 tests/built-ins/WeakRef/prototype/deref.js create mode 100644 tests/built-ins/WeakRef/prototype/toStringTag.js create mode 100644 tests/built-ins/WeakRef/subclassing.js diff --git a/docs/built-ins.md b/docs/built-ins.md index 5b950fd29..9bcc9ec97 100644 --- a/docs/built-ins.md +++ b/docs/built-ins.md @@ -299,7 +299,7 @@ The constructor-backed objects mirror the `node-semver` public fields and core i | Function | Description | |----------|-------------| | `queueMicrotask(callback)` | Enqueue a callback to run as a microtask. Throws `TypeError` if the argument is not callable. | -| `structuredClone(value)` | Deep-clone a value using the structured clone algorithm. Handles objects, arrays, `Map`, `Set`, and circular references. Throws `DOMException` with name `"DataCloneError"` (code 25) for non-cloneable types (functions, symbols, `WeakMap`, `WeakSet`). | +| `structuredClone(value)` | Deep-clone a value using the structured clone algorithm. Handles objects, arrays, `Map`, `Set`, and circular references. Throws `DOMException` with name `"DataCloneError"` (code 25) for non-cloneable types (functions, symbols, `WeakMap`, `WeakSet`, `WeakRef`, `FinalizationRegistry`). | | `btoa(data)` | Encode a binary string (each character code ≤ U+00FF) to base64. Throws `DOMException` with name `"InvalidCharacterError"` (code 5) if any character code exceeds U+00FF. | | `atob(data)` | Decode a base64 string to a binary string. Uses WHATWG forgiving-base64-decode: strips ASCII whitespace, tolerates missing `=` padding. Throws `DOMException` with name `"InvalidCharacterError"` (code 5) for invalid base64 input. | | `encodeURI(uriString)` | Encode a complete URI, preserving reserved characters (`;/?:@&=+$,#`) and unreserved characters. Multi-byte characters are UTF-8 encoded. Throws `URIError` for lone surrogates. | @@ -307,9 +307,9 @@ The constructor-backed objects mirror the `node-semver` public fields and core i | `encodeURIComponent(uriComponent)` | Encode a URI component. Only unreserved characters (`A-Z a-z 0-9 - _ . ! ~ * ' ( )`) are preserved. Throws `URIError` for lone surrogates. | | `decodeURIComponent(encodedURIComponent)` | Decode a percent-encoded URI component. Decodes all percent sequences including reserved characters. Throws `URIError` for malformed percent sequences or invalid UTF-8. | -`queueMicrotask` shares the same microtask queue used by Promise reactions. Callbacks run after the current synchronous code completes but before the engine returns control. If a callback throws, the error is silently discarded and remaining microtasks still execute. +`queueMicrotask` shares the same microtask queue used by Promise reactions. Callbacks run after the current synchronous code completes but before the engine returns control. If a callback throws, the error is surfaced as an uncaught host callback error. -`structuredClone` creates a deep copy following the HTML spec's structured clone algorithm. Primitives are returned as-is. Objects, arrays, Maps, and Sets are recursively cloned. Circular references and shared references within the object graph are preserved (the same cloned object is reused). Non-serializable values (functions, symbols, WeakMaps, WeakSets) throw a `DOMException` with `name: "DataCloneError"` and `code: 25`, matching browser and Node.js behavior. Accessor properties (getters/setters) are read via the getter and the resulting value is cloned as a data property on the clone. +`structuredClone` creates a deep copy following the HTML spec's structured clone algorithm. Primitives are returned as-is. Objects, arrays, Maps, and Sets are recursively cloned. Circular references and shared references within the object graph are preserved (the same cloned object is reused). Non-serializable values (functions, symbols, WeakMaps, WeakSets, WeakRefs, FinalizationRegistries) throw a `DOMException` with `name: "DataCloneError"` and `code: 25`, matching browser and Node.js behavior. Accessor properties (getters/setters) are read via the getter and the resulting value is cloned as a data property on the clone. `btoa` encodes a string to base64 following the WHATWG HTML spec §8.3. Each character in the input must have a code point ≤ U+00FF (Latin-1 range); characters outside this range throw a `DOMException` with name `"InvalidCharacterError"` and legacy code 5. The input is interpreted as a byte sequence where each code point maps 1:1 to a byte value. @@ -523,6 +523,33 @@ A weak key-value collection where keys are objects or non-registered symbols. We WeakMaps intentionally do **not** expose `size`, `clear`, `forEach`, iteration, `keys`, `values`, or `entries`. Keys must satisfy ECMAScript `CanBeHeldWeakly`: objects and non-registered symbols are accepted; primitives and `Symbol.for()` registry symbols throw from `set()`, upsert methods, and construction. +### WeakRef (`Goccia.Values.WeakRefValue.pas`) + +Implements the [ECMAScript WeakRef](https://tc39.es/ecma262/#sec-weak-ref-objects). + +A weak reference to an object or non-registered symbol target. A WeakRef does not keep its target alive, but `new WeakRef(target)` and `weakRef.deref()` add the target to the current job's kept-objects set so it remains stable until the next job checkpoint. + +| Method/Property | Description | +|--------|-------------| +| `new WeakRef(target)` | Create a weak reference to an object or non-registered symbol | +| `weakRef.deref()` | Return the target if it is still live, otherwise `undefined` | + +Targets must satisfy ECMAScript `CanBeHeldWeakly`: objects and non-registered symbols are accepted; primitives and `Symbol.for()` registry symbols throw from construction. `WeakRef.prototype[Symbol.toStringTag]` is `"WeakRef"`. + +### FinalizationRegistry (`Goccia.Values.FinalizationRegistryValue.pas`) + +Implements the [ECMAScript FinalizationRegistry](https://tc39.es/ecma262/#sec-finalization-registry-objects). + +A finalization registry associates weakly held targets with held values. When a target becomes unreachable and `Goccia.gc()` runs, GocciaScript enqueues a cleanup job; cleanup callbacks run after the normal microtask queue rather than synchronously inside `Goccia.gc()`. + +| Method/Property | Description | +|--------|-------------| +| `new FinalizationRegistry(cleanupCallback)` | Create a registry with a callable cleanup callback | +| `registry.register(target, heldValue, unregisterToken?)` | Register an object or non-registered symbol target with a held value | +| `registry.unregister(unregisterToken)` | Remove matching registrations and return whether anything was removed | + +Targets and unregister tokens must satisfy `CanBeHeldWeakly`. A held value may be any value except the exact same value as the target. Cleanup callbacks receive one held value argument. If a cleanup callback throws, the error is surfaced as an uncaught host callback error. + ### Promise (`Goccia.Builtins.GlobalPromise.pas`, `Goccia.Values.PromiseValue.pas`) Implements the [ECMAScript Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) including `Promise.withResolvers()` and `Promise.try()`. See [MDN Promise reference](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) for the standard API. diff --git a/docs/garbage-collector.md b/docs/garbage-collector.md index ab90c757c..f9bb3a5f9 100644 --- a/docs/garbage-collector.md +++ b/docs/garbage-collector.md @@ -6,8 +6,10 @@ - **Mark-and-sweep** — Two-phase tracing GC (`Goccia.GarbageCollector.pas`) shared by both execution modes - **Auto-registration** — Every `TGocciaValue` registers with the GC via `AfterConstruction`; subclasses override `MarkReferences` to mark owned references -- **Weak reference phase** — WeakMap and WeakSet use post-mark weak tracing/sweeping hooks so weak keys and values do not become strong roots -- **Generation-counter marking** — O(1) mark-clear via `AdvanceMark` instead of O(n) flag reset per collection +- **Weak reference phase** — WeakMap, WeakSet, WeakRef, and FinalizationRegistry use post-mark weak tracing/sweeping hooks so weak targets do not become strong roots +- **Kept objects** — WeakRef construction and `deref()` keep targets stable until the next host job checkpoint +- **Finalization cleanup** — FinalizationRegistry cleanup jobs are enqueued by GC and run after the normal microtask queue +- **Generation-counter marking** — O(1) mark-clear via `AdvanceMark` instead of O(n) flag reset per collection; explicit collections are serialized across worker threads because some intrinsic objects are shared - **Pinned singletons** — `undefined`, `null`, `true`, `false`, `NaN`, `Infinity` are pinned once at engine startup; built-in prototypes are pinned per-engine via [realm slots](core-patterns.md#realm-ownership--slot-registration) and released atomically when the realm is destroyed - **Adaptive threshold** — Collection frequency scales with surviving object count to amortize cost on large heaps @@ -16,8 +18,8 @@ Every `TGocciaValue` participates in the garbage collector: ```pascal -threadvar - GCCurrentMark: Cardinal; // Per-thread generation counter +var + GCCurrentMark: Cardinal; // Shared generation counter TGCManagedObject = class private @@ -40,8 +42,8 @@ end; ``` - **`AfterConstruction` / `BeforeDestruction`** — Every value auto-registers with the thread-local `TGarbageCollector.Instance` upon creation and unregisters before destruction so root sets cannot retain stale object pointers. -- **`MarkReferences`** — Base implementation sets `FGCMark := GCCurrentMark` (marking the object as alive for the current thread's collection). `AdvanceMark` increments the thread-local `GCCurrentMark`, and `TGarbageCollector.Instance` uses that thread-local mark while traversing objects. Subclasses override `MarkReferences` to also mark values they reference (e.g., `TGocciaObjectValue` marks its prototype and property values, `TGocciaFunctionValue` marks its closure scope, `TGocciaArrayValue` marks its elements). The `if GCMarked then Exit;` guard at the top of each override prevents re-visiting objects in cyclic reference graphs. -- **`TraceWeakReferences` / `SweepWeakReferences`** — Optional hooks for weak containers. The default implementations do nothing. WeakMap uses `TraceWeakReferences` as an ephemeron pass: if a key is already marked by normal roots, its value is marked, but the key is never marked by the map. WeakMap and WeakSet use `SweepWeakReferences` to remove entries whose keys/values remain unmarked. +- **`MarkReferences`** — Base implementation sets `FGCMark := GCCurrentMark` (marking the object as alive for the current collection). `AdvanceMark` increments the shared `GCCurrentMark` while the collector lock is held, and `TGarbageCollector.Instance` uses that mark while traversing objects. Subclasses override `MarkReferences` to also mark values they reference (e.g., `TGocciaObjectValue` marks its prototype and property values, `TGocciaFunctionValue` marks its closure scope, `TGocciaArrayValue` marks its elements). The `if GCMarked then Exit;` guard at the top of each override prevents re-visiting objects in cyclic reference graphs. +- **`TraceWeakReferences` / `SweepWeakReferences`** — Optional hooks for weak containers and weak references. The default implementations do nothing. WeakMap uses `TraceWeakReferences` as an ephemeron pass: if a key is already marked by normal roots, its value is marked, but the key is never marked by the map. WeakMap and WeakSet use `SweepWeakReferences` to remove entries whose keys/values remain unmarked. WeakRef clears an unmarked target, and FinalizationRegistry removes dead cells while enqueueing cleanup jobs for their held values. - **`RuntimeCopy`** — Creates a fresh GC-managed copy of the value. Used by the evaluator when evaluating literal expressions: AST-owned literal values are not tracked by the GC, so `RuntimeCopy` produces a runtime value that is. The default implementation returns `Self` (for singletons and complex values). Primitives override this: numbers use the `SmallInt` cache for 0-255, booleans return singletons, strings create new instances (cheap due to copy-on-write). ## Contributor Rules @@ -53,10 +55,12 @@ When working with the GC, follow these rules: - **Realm-owned pinning** — Built-in prototypes are stored in per-engine [realm slots](core-patterns.md#realm-ownership--slot-registration). `TGocciaRealm.SetSlot` pins the stored object via `PinObject`; the realm tracks every pin it took and releases all of them in `Destroy` via `UnpinObject`. Owned-slot helpers (`TGocciaSharedPrototype` instances) are `Free`d before the pin-release pass, so their destructors can still call `UnpinObject` on objects they own. This means engine tear-down releases the entire intrinsic prototype graph atomically — embedders should not pin or unpin built-in prototypes manually. - **Protect stack-held values** — Values held only by Pascal code (not in any GocciaScript scope) must be protected with `AddTempRoot`/`RemoveTempRoot`. - **Use `CollectIfNeeded(AProtect)`** when holding a `TGCManagedObject` on the stack. The no-arg `CollectIfNeeded` is only safe when all live values are already rooted. -- **Weak containers must not mark keys during `MarkReferences`**. Put weak-value propagation in `TraceWeakReferences` and dead-entry pruning in `SweepWeakReferences`; otherwise WeakMap/WeakSet semantics collapse into strong Map/Set semantics. +- **Weak containers and weak references must not mark weak targets during `MarkReferences`**. Put weak-value propagation in `TraceWeakReferences` and dead-target pruning or cleanup scheduling in `SweepWeakReferences`; otherwise weak semantics collapse into strong references. +- **Queued jobs must root their callback payloads**. Promise reactions, `queueMicrotask` callbacks, and FinalizationRegistry cleanup jobs use queued roots so callback functions, held values, and result promises survive collections until the job runs. +- **Clear kept objects at host job boundaries**. Engine idle checkpoints and the shared microtask/fetch drain helper clear the kept-objects set before and after draining; individual microtask/finalization jobs clear it after they complete. - **Scopes** register with the GC in their constructor and unregister through `BeforeDestruction`. Active call scopes are tracked via `PushActiveRoot`/`PopActiveRoot`. - **VM register rooting** uses a bytecode VM stack root and only traverses object-bearing register slots. -- Automatic collection is disabled during bytecode execution. CLI hosts may still call `Collect` explicitly between files; the benchmark runner does this after each benchmark file, while parallel test workers reclaim their thread-local GC heap at worker shutdown. +- Automatic collection is disabled during bytecode execution. CLI hosts may still call `Collect` explicitly between files; the benchmark runner does this after each benchmark file, while parallel test workers reclaim their thread-local GC heap at worker shutdown. Explicit `Goccia.gc()` is still available in worker threads and is serialized by the collector lock. ## Design Rationale @@ -75,10 +79,10 @@ When working with the GC, follow these rules: - **Simplicity** — Two phases (mark reachable, sweep unreachable) with straightforward implementation. - **Handles cycles** — Circular references between objects, closures, and scopes are collected correctly. - **O(1) membership checks** — Pinned objects, temp roots, and root objects are stored in `THashMap` (`TGCObjectSet`) for O(1) `PinObject`, `AddRootObject`, `AddTempRoot`, and `RemoveTempRoot` operations, avoiding O(n) linear scans on every allocation. -- **Generation-counter mark tracking** — Instead of clearing the `GCMarked` flag on every object at the start of each collection (an O(n) pass), the GC uses a generation counter (`TGCManagedObject.FCurrentMark`). `AdvanceMark` increments the counter in O(1), and an object is considered "marked" when its `FGCMark` matches `FCurrentMark`. This eliminates a full pass over the managed objects list per collection. +- **Generation-counter mark tracking** — Instead of clearing the `GCMarked` flag on every object at the start of each collection (an O(n) pass), the GC uses a generation counter. `AdvanceMark` increments the counter in O(1), and an object is considered "marked" when its `FGCMark` matches the current generation. This eliminates a full pass over the managed objects list per collection. The counter is shared across threads, and full/young collection holds a global collector lock so shared intrinsic objects cannot race on mark state. - **O(1) `UnregisterObject`** — Each managed object stores its index in the managed objects list (`GCIndex`). Unregistration nils the slot at the known index instead of performing an O(n) linear scan. The sweep phase compacts nil slots during its existing pass. - **Adaptive threshold** — After each collection, the threshold scales to `max(DEFAULT_GC_THRESHOLD, surviving_count)`, so large heaps collect proportionally less often, amortizing collection cost to O(1) per allocation. -- **Weak fixed-point tracing** — After normal root marking, the collector repeatedly visits marked objects' weak hooks until no hook marks anything new. This handles ephemeron chains such as a live WeakMap key exposing a value that then keeps another WeakMap key alive. After the fixed point, marked weak containers sweep entries whose weak keys or values are still unmarked, then the normal object sweep frees unreachable objects. +- **Weak fixed-point tracing** — After normal root marking, the collector repeatedly visits marked objects' weak hooks until no hook marks anything new. This handles ephemeron chains such as a live WeakMap key exposing a value that then keeps another WeakMap key alive. After the fixed point, marked weak containers sweep entries whose weak keys or values are still unmarked, WeakRefs clear dead targets, FinalizationRegistries enqueue cleanup jobs for dead cells, and then the normal object sweep frees unreachable objects. - **`Recycle` virtual method** — Sweep calls `Obj.Recycle` instead of `Obj.Free`. The default calls `Free`, but subclasses can override to return objects to a pool. - **Measurable impact** — Both the GocciaBenchmarkRunner and GocciaTestRunner call `Collect` after each file to reclaim memory between script executions. @@ -121,7 +125,7 @@ Each worker thread creates its own `TGarbageCollector` instance via `threadvar`. Key behavior on worker threads: -- **Automatic GC collection is disabled** (`Enabled := False`) to avoid `FGCMark` races on shared immutable objects (primitive singletons, shared prototypes). Explicit host-side `Collect` calls can still run; `GocciaBenchmarkRunner` uses this after each benchmark file, while `GocciaTestRunner` avoids worker-side collection and lets worker shutdown reclaim the thread-local GC heap. +- **Automatic GC collection is disabled** (`Enabled := False`) so worker execution does not collect between ordinary allocations. Explicit `Collect` calls still run under the global collector lock; `Goccia.gc()` therefore has the same observable behavior in worker threads as on the main thread. `GocciaTestRunner` still lets worker shutdown reclaim each thread-local GC heap instead of collecting after every file. - **`BytesAllocated` still increments** on every allocation, even with automatic collection disabled. Without explicit host collection, the counter grows across all files a worker processes. - **The memory ceiling check still fires.** The limit check in `TGocciaValue.AfterConstruction` does not depend on `GC.Enabled` — it checks `MaxBytes > 0` and `BytesAllocated > MaxBytes` regardless. This is the sole protection against unbounded memory growth on workers. - **No pre-allocation.** `MaxBytes` is a threshold, not a reservation. Memory is allocated on demand by the FPC heap manager; the GC only checks whether the running total exceeds the ceiling. @@ -133,7 +137,7 @@ The separate `memory.heap` JSON object comes from FreePascal's `GetHeapStatus`, ## JavaScript API -`Goccia.gc()` manually triggers a full mark-and-sweep collection on the main thread, bypassing the automatic collection threshold. Active interpreter calls and bytecode VM registers are treated as roots while collection runs. On worker threads the call is a no-op because shared immutable objects (singletons, prototypes) have a single `FGCMark` field that is not thread-safe for concurrent marking. It is safe to call repeatedly and returns `undefined`. +`Goccia.gc()` manually triggers a full mark-and-sweep collection, bypassing the automatic collection threshold. Active interpreter calls and bytecode VM registers are treated as roots while collection runs. Collections are serialized by a global collector lock so explicit calls are safe in parallel test workers even though intrinsic prototype objects can be shared. It is safe to call repeatedly and returns `undefined`. | Property | Type | Description | |----------|------|-------------| diff --git a/docs/interpreter.md b/docs/interpreter.md index ad50c2e39..159f18cbd 100644 --- a/docs/interpreter.md +++ b/docs/interpreter.md @@ -171,11 +171,11 @@ This follows the ECMAScript specification's microtask ordering semantics. Thenab For fetch-backed Promises, these integration points also pump fetch completions before treating a pending Promise as permanently unsettled. -**`queueMicrotask`:** The global `queueMicrotask(callback)` function enqueues a user-provided callback into the same microtask queue used by Promise reactions. This matches the [HTML spec](https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#microtask-queuing). If a `queueMicrotask` callback throws, the error is silently discarded and the queue keeps draining — remaining microtasks and Promise reactions still run. This matches the observable behavior in Node.js/browsers where uncaught microtask errors don't prevent other microtasks from executing. +**`queueMicrotask`:** The global `queueMicrotask(callback)` function enqueues a user-provided callback into the same microtask queue used by Promise reactions. This matches the [HTML spec](https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#microtask-queuing). If a `queueMicrotask` callback throws, the error is surfaced as an uncaught host callback error instead of being converted into a Promise rejection. Promise reaction handler errors still reject their result promises. **Error safety:** `TGocciaEngine.Execute` wraps the whole source pipeline and execution path in a `try..finally` that calls `ClearQueue` and discards pending fetch completions. If the interpreter throws, stale microtasks and fetch callbacks are discarded rather than leaking into subsequent executions; outstanding fetch workers are detached so cleanup does not wait on network I/O that can no longer affect the script. Lower-level callers that bypass `Execute` and call `ExecuteProgram` directly still get the idle drain, but they own any surrounding runtime cleanup. -**GC safety:** During `DrainQueue`, each microtask's handler, value, and result promise are temp-rooted to prevent collection mid-callback. +**GC safety:** During `DrainQueue`, each microtask's handler, value, and result promise are temp-rooted to prevent collection mid-callback. Queued microtasks and FinalizationRegistry cleanup jobs are also registered as queued GC roots until they run. ## Related documents diff --git a/docs/language-tables.md b/docs/language-tables.md index b6dbc9fc9..39a1be897 100644 --- a/docs/language-tables.md +++ b/docs/language-tables.md @@ -45,6 +45,7 @@ | Nullish coalescing (`??`) | ES2020 | Supported | | Optional chaining (`?.`) | ES2020 | Supported | | `BigInt` | ES2020 | Supported | +| `WeakRef`, `FinalizationRegistry` | ES2021 | Supported | | Logical assignment (`&&=`, `\|\|=`, `??=`) | ES2021 | Supported | | Private fields and methods (`#field`) | ES2022 | Supported | | Static class blocks | ES2022 | Supported | diff --git a/docs/language.md b/docs/language.md index e543acfed..8dfb7f0f5 100644 --- a/docs/language.md +++ b/docs/language.md @@ -645,6 +645,14 @@ Get existing value or insert a default/computed value: `map.getOrInsert(key, def WeakMap also implements the ES2026 upsert methods: `weakMap.getOrInsert(key, default)` and `weakMap.getOrInsertComputed(key, callbackFn)`. WeakMap keys must be objects or non-registered symbols. +### WeakRef and FinalizationRegistry (ES2021) + +Weak references and finalization registries are supported for objects and non-registered symbols, matching the existing weak-key domain used by WeakMap and WeakSet. Primitives and `Symbol.for()` registry symbols are rejected because they cannot be held weakly. + +`new WeakRef(target)` creates a weak reference. `weakRef.deref()` returns the target while it is still live and returns `undefined` after the target has been collected. Both construction and `deref()` add the target to the current job's kept-objects set, so repeated `deref()` calls in the same job are stable. + +`new FinalizationRegistry(cleanupCallback)` creates a registry. `registry.register(target, heldValue, unregisterToken?)` registers a weak target and held value, and `registry.unregister(unregisterToken)` removes matching registrations. Explicit `Goccia.gc()` performs collection and enqueues cleanup jobs; cleanup callbacks run through the runtime's idle checkpoint after the normal microtask queue, not synchronously inside `Goccia.gc()`. Cleanup callback errors are surfaced as uncaught host callback errors. + ### Error.isError (ES2026) Reliable brand check for error objects: `Error.isError(value)`. See [ES2026 §20.5.3.2](https://tc39.es/ecma262/#sec-error.iserror). @@ -857,12 +865,6 @@ When enabled, labels can target `break` and `continue` statements in interpreter Generator method shorthand (`*method()` and `async *method()`) is supported by default. Generator function syntax (`function*` and `async function*`) is supported only when `--compat-function` is enabled. Iterator protocol and Iterator Helpers are also implemented. -### Deferred Built-ins - -The following standard ECMAScript built-ins are **not yet implemented** and may be added in future versions: - -- **WeakRef / FinalizationRegistry** — Weak references and finalizers. Deferred until demand warrants the additional cleanup scheduling complexity. - ## Intentional Divergences from ECMAScript These are deliberate differences from standard ECMAScript behavior, not missing features. diff --git a/docs/value-system.md b/docs/value-system.md index 161eeb35b..fd4963056 100644 --- a/docs/value-system.md +++ b/docs/value-system.md @@ -7,7 +7,7 @@ - **`TGocciaValue` hierarchy** — All runtime values inherit from `TGocciaValue`; primitives, objects, arrays, functions, classes, iterators, and typed arrays each have dedicated subclasses - **Virtual property access** — `GetProperty`/`SetProperty` are virtual methods on the base class, eliminating type checks at call sites - **GC integration** — Every value auto-registers via `AfterConstruction`; subclasses override `MarkReferences` to mark owned references -- **Shared prototype singletons** — String, Number, Array, Set, Map, WeakSet, WeakMap, Symbol, Function, and TypedArray types share a single prototype instance per engine; the prototype lives in a [realm slot](core-patterns.md#realm-ownership--slot-registration) and is unpinned when the engine is freed +- **Shared prototype singletons** — String, Number, Array, Set, Map, WeakSet, WeakMap, WeakRef, FinalizationRegistry, Symbol, Function, and TypedArray types share a single prototype instance per engine; the prototype lives in a [realm slot](core-patterns.md#realm-ownership--slot-registration) and is unpinned when the engine is freed The value system is the foundation of GocciaScript's runtime. Every piece of data — numbers, strings, objects, functions — is represented as a `TGocciaValue` or one of its subclasses. @@ -35,6 +35,8 @@ classDiagram TGocciaObjectValue <|-- TGocciaInstanceValue TGocciaInstanceValue <|-- TGocciaWeakSetValue TGocciaInstanceValue <|-- TGocciaWeakMapValue + TGocciaInstanceValue <|-- TGocciaWeakRefValue + TGocciaInstanceValue <|-- TGocciaFinalizationRegistryValue TGocciaObjectValue <|-- TGocciaEnumValue TGocciaObjectValue <|-- TGocciaIteratorValue TGocciaIteratorValue <|-- TGocciaArrayIteratorValue @@ -463,13 +465,15 @@ Each helper creates a `TGocciaObjectValue` with `name` and `message` properties ## Weak Collections -`TGocciaWeakMapValue` and `TGocciaWeakSetValue` extend `TGocciaInstanceValue` (`Goccia.Values.WeakMapValue.pas`, `Goccia.Values.WeakSetValue.pas`). They are class-backed native instances so subclassing and constructor semantics match other built-in native classes. +`TGocciaWeakMapValue`, `TGocciaWeakSetValue`, `TGocciaWeakRefValue`, and `TGocciaFinalizationRegistryValue` extend `TGocciaInstanceValue` (`Goccia.Values.WeakMapValue.pas`, `Goccia.Values.WeakSetValue.pas`, `Goccia.Values.WeakRefValue.pas`, `Goccia.Values.FinalizationRegistryValue.pas`). They are class-backed native instances so subclassing and constructor semantics match other built-in native classes. - **Weak eligibility** — `Goccia.Values.WeakReferenceSupport.CanBeHeldWeakly` accepts objects and non-registered symbols. Primitives and `Symbol.for()` registry symbols are rejected by mutators and constructors. - **Internal storage** — WeakMap stores entries in `THashMap`; WeakSet stores membership in `THashMap`. Allowed keys are object/symbol identity values, so pointer identity matches the required `SameValue` behavior for this domain. - **No enumeration surface** — WeakMap/WeakSet intentionally do not expose `size`, `clear`, `forEach`, iterators, `keys`, `values`, or `entries`. - **Shared prototype singleton** — Each weak collection has a per-engine shared prototype stored in a realm-owned slot. Prototype methods operate through `ThisValue`, matching the built-in method-host pattern. -- **GC behavior** — WeakMap/WeakSet do not mark weak keys/values during normal `MarkReferences`. WeakMap's weak tracing hook marks a value only when its key is already live from outside the map, and sweeping removes entries whose keys remain unmarked. WeakSet sweeping removes unmarked members. +- **WeakRef kept objects** — WeakRef construction and `deref()` add the target to the GC kept-objects set. The set is cleared at host job checkpoints so repeated `deref()` calls in the same job are stable without making the target strongly reachable forever. +- **FinalizationRegistry cleanup** — FinalizationRegistry marks cleanup callbacks and held values, but never marks registered targets or unregister tokens. Sweeping dead cells queues cleanup jobs through the microtask queue's finalization lane. Cleanup jobs run after normal microtasks and root their held values until execution. +- **GC behavior** — WeakMap/WeakSet do not mark weak keys/values during normal `MarkReferences`. WeakMap's weak tracing hook marks a value only when its key is already live from outside the map, and sweeping removes entries whose keys remain unmarked. WeakSet sweeping removes unmarked members. WeakRef clears an unmarked target. FinalizationRegistry removes unmarked-target cells and schedules cleanup. ## Promises diff --git a/source/app/GocciaTestRunner.dpr b/source/app/GocciaTestRunner.dpr index c5e9af6c6..a2473cf9d 100644 --- a/source/app/GocciaTestRunner.dpr +++ b/source/app/GocciaTestRunner.dpr @@ -1032,9 +1032,9 @@ begin end; end; - // No GC.Collect — worker GC is disabled to avoid FGCMark races on - // shared objects. All thread-local objects are freed in bulk when - // ShutdownThreadRuntime destroys the thread-local GC. + // No per-file GC.Collect here. Explicit script-level Goccia.gc() is + // serialized by the collector lock, but the runner still lets worker + // shutdown reclaim each thread-local heap in bulk. end; function TTestRunnerApp.RunScriptsFromFilesParallel( diff --git a/source/units/Goccia.Builtins.GlobalPromise.pas b/source/units/Goccia.Builtins.GlobalPromise.pas index c74ae11c5..e03617d78 100644 --- a/source/units/Goccia.Builtins.GlobalPromise.pas +++ b/source/units/Goccia.Builtins.GlobalPromise.pas @@ -522,6 +522,8 @@ function TGocciaGlobalPromise.PromiseConstructorFn(const AArgs: TGocciaArguments { Step 7: Let resolvingFunctions = CreateResolvingFunctions(promise) } ResolveFn := TGocciaNativeFunctionValue.CreateWithoutPrototype(Promise.DoResolve, 'resolve', 1); RejectFn := TGocciaNativeFunctionValue.CreateWithoutPrototype(Promise.DoReject, 'reject', 1); + ResolveFn.CapturedRoot := Promise; + RejectFn.CapturedRoot := Promise; { Step 8: Let completion = Call(executor, undefined, « resolve, reject ») } ExecutorArgs := TGocciaArgumentsCollection.Create([ResolveFn, RejectFn]); @@ -579,6 +581,8 @@ function TGocciaGlobalPromise.PromiseConstruct(const AArgs: TGocciaArgumentsColl ResolveFn := TGocciaNativeFunctionValue.CreateWithoutPrototype(Promise.DoResolve, 'resolve', 1); RejectFn := TGocciaNativeFunctionValue.CreateWithoutPrototype(Promise.DoReject, 'reject', 1); + ResolveFn.CapturedRoot := Promise; + RejectFn.CapturedRoot := Promise; ExecutorArgs := TGocciaArgumentsCollection.Create([ResolveFn, RejectFn]); try @@ -995,6 +999,8 @@ function TGocciaGlobalPromise.PromiseWithResolvers(const AArgs: TGocciaArguments Promise := TGocciaPromiseValue.Create; ResolveFn := TGocciaNativeFunctionValue.CreateWithoutPrototype(Promise.DoResolve, 'resolve', 1); RejectFn := TGocciaNativeFunctionValue.CreateWithoutPrototype(Promise.DoReject, 'reject', 1); + ResolveFn.CapturedRoot := Promise; + RejectFn.CapturedRoot := Promise; { Steps 3-6: Create result object with promise, resolve, reject properties } ResultObj := TGocciaObjectValue.Create; diff --git a/source/units/Goccia.Builtins.Globals.pas b/source/units/Goccia.Builtins.Globals.pas index fa946ddee..5e91c6e6e 100644 --- a/source/units/Goccia.Builtins.Globals.pas +++ b/source/units/Goccia.Builtins.Globals.pas @@ -99,6 +99,7 @@ implementation Goccia.Values.ArrayBufferValue, Goccia.Values.ArrayValue, Goccia.Values.ErrorHelper, + Goccia.Values.FinalizationRegistryValue, Goccia.Values.FunctionBase, Goccia.Values.HoleValue, Goccia.Values.MapValue, @@ -108,6 +109,7 @@ implementation Goccia.Values.SharedArrayBufferValue, Goccia.Values.SymbolValue, Goccia.Values.WeakMapValue, + Goccia.Values.WeakRefValue, Goccia.Values.WeakSetValue; var @@ -699,8 +701,6 @@ function TGocciaGlobals.QueueMicrotaskCallback(const AArgs: TGocciaArgumentsColl Task.Value := TGocciaUndefinedLiteralValue.UndefinedValue; Task.ReactionType := prtFulfill; - if Assigned(TGarbageCollector.Instance) then - TGarbageCollector.Instance.AddTempRoot(Callback); TGocciaMicrotaskQueue.Instance.Enqueue(Task); { Step 3: Return undefined } @@ -862,6 +862,10 @@ function StructuredCloneValue(const AValue: TGocciaValue; ThrowDataCloneError(Format(SErrorStructuredCloneNotCloneable, [CONSTRUCTOR_WEAK_MAP]), SSuggestStructuredClone) else if AValue is TGocciaWeakSetValue then ThrowDataCloneError(Format(SErrorStructuredCloneNotCloneable, [CONSTRUCTOR_WEAK_SET]), SSuggestStructuredClone) + else if AValue is TGocciaWeakRefValue then + ThrowDataCloneError(Format(SErrorStructuredCloneNotCloneable, [CONSTRUCTOR_WEAK_REF]), SSuggestStructuredClone) + else if AValue is TGocciaFinalizationRegistryValue then + ThrowDataCloneError(Format(SErrorStructuredCloneNotCloneable, [CONSTRUCTOR_FINALIZATION_REGISTRY]), SSuggestStructuredClone) else if AValue is TGocciaObjectValue then Result := CloneObject(TGocciaObjectValue(AValue), AMemory) else diff --git a/source/units/Goccia.Constants.ConstructorNames.pas b/source/units/Goccia.Constants.ConstructorNames.pas index aba213ce8..7cdf76b1b 100644 --- a/source/units/Goccia.Constants.ConstructorNames.pas +++ b/source/units/Goccia.Constants.ConstructorNames.pas @@ -18,6 +18,8 @@ interface CONSTRUCTOR_MAP = 'Map'; CONSTRUCTOR_WEAK_SET = 'WeakSet'; CONSTRUCTOR_WEAK_MAP = 'WeakMap'; + CONSTRUCTOR_WEAK_REF = 'WeakRef'; + CONSTRUCTOR_FINALIZATION_REGISTRY = 'FinalizationRegistry'; CONSTRUCTOR_PROXY = 'Proxy'; CONSTRUCTOR_PERFORMANCE = 'Performance'; CONSTRUCTOR_PROMISE = 'Promise'; diff --git a/source/units/Goccia.Engine.pas b/source/units/Goccia.Engine.pas index 24c0fdd83..3dc005236 100644 --- a/source/units/Goccia.Engine.pas +++ b/source/units/Goccia.Engine.pas @@ -308,6 +308,7 @@ implementation Goccia.Values.ArrayValue, Goccia.Values.BooleanObjectValue, Goccia.Values.ErrorHelper, + Goccia.Values.FinalizationRegistryValue, Goccia.Values.FunctionValue, Goccia.Values.MapValue, Goccia.Values.NativeFunction, @@ -319,6 +320,7 @@ implementation Goccia.Values.SymbolValue, Goccia.Values.Uint8ArrayEncoding, Goccia.Values.WeakMapValue, + Goccia.Values.WeakRefValue, Goccia.Values.WeakSetValue, Goccia.Version; @@ -749,6 +751,16 @@ procedure ExposeWeakSetPrototype(const AConstructor: TGocciaValue); TGocciaWeakSetValue.ExposePrototype(AConstructor); end; +procedure ExposeWeakRefPrototype(const AConstructor: TGocciaValue); +begin + TGocciaWeakRefValue.ExposePrototype(AConstructor); +end; + +procedure ExposeFinalizationRegistryPrototype(const AConstructor: TGocciaValue); +begin + TGocciaFinalizationRegistryValue.ExposePrototype(AConstructor); +end; + procedure TGocciaEngine.RegisterBuiltinConstructors; var Key: string; @@ -759,6 +771,8 @@ procedure TGocciaEngine.RegisterBuiltinConstructors; SetConstructor: TGocciaSetClassValue; WeakMapConstructor: TGocciaWeakMapClassValue; WeakSetConstructor: TGocciaWeakSetClassValue; + WeakRefConstructor: TGocciaWeakRefClassValue; + FinalizationRegistryConstructor: TGocciaFinalizationRegistryClassValue; ArrayBufferConstructor: TGocciaArrayBufferClassValue; SharedArrayBufferConstructor: TGocciaSharedArrayBufferClassValue; StringConstructor: TGocciaStringClassValue; @@ -833,6 +847,29 @@ procedure TGocciaEngine.RegisterBuiltinConstructors; RegisterTypeDefinition(FInterpreter.GlobalScope, TypeDef, SpeciesGetter, GenericConstructor); WeakSetConstructor := TGocciaWeakSetClassValue(GenericConstructor); + TypeDef.ConstructorName := CONSTRUCTOR_WEAK_REF; + TypeDef.Kind := gtdkNativeInstanceType; + TypeDef.ClassValueClass := TGocciaWeakRefClassValue; + TypeDef.ExposePrototype := @ExposeWeakRefPrototype; + TypeDef.PrototypeProvider := nil; + TypeDef.StaticSource := nil; + TypeDef.PrototypeParent := ObjectConstructor.Prototype; + TypeDef.AddSpeciesGetter := False; + RegisterTypeDefinition(FInterpreter.GlobalScope, TypeDef, SpeciesGetter, GenericConstructor); + WeakRefConstructor := TGocciaWeakRefClassValue(GenericConstructor); + + TypeDef.ConstructorName := CONSTRUCTOR_FINALIZATION_REGISTRY; + TypeDef.Kind := gtdkNativeInstanceType; + TypeDef.ClassValueClass := TGocciaFinalizationRegistryClassValue; + TypeDef.ExposePrototype := @ExposeFinalizationRegistryPrototype; + TypeDef.PrototypeProvider := nil; + TypeDef.StaticSource := nil; + TypeDef.PrototypeParent := ObjectConstructor.Prototype; + TypeDef.AddSpeciesGetter := False; + RegisterTypeDefinition(FInterpreter.GlobalScope, TypeDef, SpeciesGetter, GenericConstructor); + FinalizationRegistryConstructor := + TGocciaFinalizationRegistryClassValue(GenericConstructor); + ArrayBufferConstructor := TGocciaArrayBufferClassValue.Create(CONSTRUCTOR_ARRAY_BUFFER, nil); TGocciaArrayBufferValue.ExposePrototype(ArrayBufferConstructor); ArrayBufferConstructor.Prototype.Prototype := ObjectConstructor.Prototype; @@ -932,6 +969,8 @@ procedure TGocciaEngine.RegisterBuiltinConstructors; TGocciaClassValue.PatchDefaultPrototype(SetConstructor); TGocciaClassValue.PatchDefaultPrototype(WeakMapConstructor); TGocciaClassValue.PatchDefaultPrototype(WeakSetConstructor); + TGocciaClassValue.PatchDefaultPrototype(WeakRefConstructor); + TGocciaClassValue.PatchDefaultPrototype(FinalizationRegistryConstructor); TGocciaClassValue.PatchDefaultPrototype(ArrayBufferConstructor); TGocciaClassValue.PatchDefaultPrototype(SharedArrayBufferConstructor); TGocciaClassValue.PatchDefaultPrototype(FTypedArrayIntrinsic); @@ -1122,12 +1161,22 @@ procedure TGocciaEngine.WaitForRuntimeIdle; var I: Integer; Queue: TGocciaMicrotaskQueue; + GC: TGarbageCollector; begin - for I := 0 to FExtensions.Count - 1 do - FExtensions[I].WaitForIdle; - Queue := TGocciaMicrotaskQueue.Instance; - if Assigned(Queue) and Queue.HasPending then - Queue.DrainQueue; + try + for I := 0 to FExtensions.Count - 1 do + FExtensions[I].WaitForIdle; + GC := TGarbageCollector.Instance; + if Assigned(GC) then + GC.ClearKeptObjects; + Queue := TGocciaMicrotaskQueue.Instance; + if Assigned(Queue) and Queue.HasPending then + Queue.DrainQueue; + finally + GC := TGarbageCollector.Instance; + if Assigned(GC) then + GC.ClearKeptObjects; + end; end; procedure TGocciaEngine.DoRetainModule(const AModule: TObject); @@ -1427,10 +1476,7 @@ function TGocciaEngine.GocciaGC(const AArgs: TGocciaArgumentsCollection; const A GC: TGarbageCollector; begin GC := TGarbageCollector.Instance; - // Skip on worker threads: shared immutable objects (singletons, prototypes) - // have a single FGCMark field that is not thread-safe — running mark-sweep - // on a worker would race on that field and crash. - if Assigned(GC) and (not GIsWorkerThread) then + if Assigned(GC) then GC.Collect; Result := TGocciaUndefinedLiteralValue.UndefinedValue; end; diff --git a/source/units/Goccia.Error.Messages.pas b/source/units/Goccia.Error.Messages.pas index a53ac84d8..c22d32649 100644 --- a/source/units/Goccia.Error.Messages.pas +++ b/source/units/Goccia.Error.Messages.pas @@ -682,6 +682,11 @@ interface SErrorWeakCollectionInvalidKey = '%s: key must be an object or non-registered symbol'; SErrorWeakMapConstructorEntryNotObject = 'WeakMap constructor requires each entry to be an object'; SErrorWeakCollectionConstructorNotIterable = '%s constructor requires an iterable'; + SErrorWeakRefDerefNonWeakRef = 'WeakRef.prototype.deref called on non-WeakRef object'; + SErrorFinalizationRegistryCleanupNotCallable = 'FinalizationRegistry cleanup callback must be callable'; + SErrorFinalizationRegistryRegisterNonRegistry = 'FinalizationRegistry.prototype.register called on non-FinalizationRegistry object'; + SErrorFinalizationRegistryUnregisterNonRegistry = 'FinalizationRegistry.prototype.unregister called on non-FinalizationRegistry object'; + SErrorFinalizationRegistryTargetHeldValueSame = 'FinalizationRegistry.prototype.register target and heldValue must not be the same value'; // Set/Map forEach errors SErrorSetForEachNotCallable = 'Set.prototype.forEach: callback is not a function'; diff --git a/source/units/Goccia.Error.Suggestions.pas b/source/units/Goccia.Error.Suggestions.pas index 2f12e8575..c85d7a932 100644 --- a/source/units/Goccia.Error.Suggestions.pas +++ b/source/units/Goccia.Error.Suggestions.pas @@ -344,7 +344,10 @@ interface // Runtime errors — WeakMap / WeakSet SSuggestWeakMapThisType = 'WeakMap prototype methods must be called on a WeakMap instance'; SSuggestWeakSetThisType = 'WeakSet prototype methods must be called on a WeakSet instance'; + SSuggestWeakRefThisType = 'WeakRef prototype methods must be called on a WeakRef instance'; + SSuggestFinalizationRegistryThisType = 'FinalizationRegistry prototype methods must be called on a FinalizationRegistry instance'; SSuggestWeakCollectionKey = 'use an object or a Symbol() value; registered symbols from Symbol.for() and primitive values cannot be held weakly'; + SSuggestFinalizationRegistryHeldValue = 'pass cleanup data that does not strongly reference the target'; // Runtime errors — Number SSuggestNumberRange = 'the argument must be within the valid range'; diff --git a/source/units/Goccia.FetchManager.pas b/source/units/Goccia.FetchManager.pas index 2f5cbe1e7..ea6236c6d 100644 --- a/source/units/Goccia.FetchManager.pas +++ b/source/units/Goccia.FetchManager.pas @@ -576,24 +576,34 @@ procedure TGocciaFetchManagerImpl.DiscardPending; procedure DrainMicrotasksAndFetchCompletions; var DidWork: Boolean; + GC: TGarbageCollector; Queue: TGocciaMicrotaskQueue; Manager: TGocciaFetchManager; begin - repeat - DidWork := False; - - Queue := TGocciaMicrotaskQueue.Instance; - if Assigned(Queue) and Queue.HasPending then - begin - Queue.DrainQueue; - DidWork := True; - end; - - Manager := TGocciaFetchManager.Instance; - if Assigned(Manager) and Manager.HasPending and - (Manager.PumpCompletions > 0) then - DidWork := True; - until not DidWork; + GC := TGarbageCollector.Instance; + try + if Assigned(GC) then + GC.ClearKeptObjects; + + repeat + DidWork := False; + + Queue := TGocciaMicrotaskQueue.Instance; + if Assigned(Queue) and Queue.HasPending then + begin + Queue.DrainQueue; + DidWork := True; + end; + + Manager := TGocciaFetchManager.Instance; + if Assigned(Manager) and Manager.HasPending and + (Manager.PumpCompletions > 0) then + DidWork := True; + until not DidWork; + finally + if Assigned(GC) then + GC.ClearKeptObjects; + end; end; function WaitForFetchPromise(const APromise: TGocciaPromiseValue): Boolean; diff --git a/source/units/Goccia.GarbageCollector.pas b/source/units/Goccia.GarbageCollector.pas index ed2a6d477..3ab12797d 100644 --- a/source/units/Goccia.GarbageCollector.pas +++ b/source/units/Goccia.GarbageCollector.pas @@ -32,6 +32,7 @@ TGCManagedObject = class TGCManagedObjectList = TObjectList; TGCObjectSet = THashMap; + TGCObjectRefCounts = THashMap; // Initialize stack-local roots with InitializeTempRoot before first use. TGocciaTempRoot = record @@ -44,6 +45,8 @@ TGarbageCollector = class FManagedObjects: TGCManagedObjectList; FPinnedObjects: TGCObjectSet; FTempRoots: TGCObjectSet; + FQueuedRoots: TGCObjectRefCounts; + FKeptObjects: TGCObjectSet; FRootObjects: TGCObjectSet; FActiveRootStack: TGCManagedObjectList; @@ -92,6 +95,10 @@ TGarbageCollector = class procedure AddTempRoot(const AObject: TGCManagedObject); procedure RemoveTempRoot(const AObject: TGCManagedObject); function IsTempRoot(const AObject: TGCManagedObject): Boolean; + procedure AddQueuedRoot(const AObject: TGCManagedObject); + procedure RemoveQueuedRoot(const AObject: TGCManagedObject); + procedure AddKeptObject(const AObject: TGCManagedObject); + procedure ClearKeptObjects; procedure AddRootObject(const AObject: TGCManagedObject); procedure RemoveRootObject(const AObject: TGCManagedObject); @@ -164,11 +171,13 @@ procedure RemoveTempRootIfNeeded(var ARoot: TGocciaTempRoot); implementation -{$IF DEFINED(GC_DEBUG) OR DEFINED(GC_TIMING)} uses + SyncObjs + {$IF DEFINED(GC_DEBUG) OR DEFINED(GC_TIMING)} + , SysUtils - {$IFDEF GC_TIMING}, TimingUtils{$ENDIF}; -{$ENDIF} + {$IFDEF GC_TIMING}, TimingUtils{$ENDIF} + {$ENDIF}; function DetectDefaultMaxBytes: Int64; var @@ -190,8 +199,11 @@ function DetectDefaultMaxBytes: Int64; Result := DEFAULT_MAX_BYTES; end; -threadvar +var GCCurrentMark: Cardinal; + GCCollectLock: TRTLCriticalSection; + +threadvar GCThreadInstance: TGarbageCollector; { TGCManagedObject } @@ -297,7 +309,8 @@ class procedure TGarbageCollector.Initialize; if not Assigned(GCThreadInstance) then begin GCThreadInstance := TGarbageCollector.Create; - GCCurrentMark := 1; + if GCCurrentMark = 0 then + GCCurrentMark := 1; end; end; @@ -313,6 +326,8 @@ constructor TGarbageCollector.Create; FManagedObjects := TGCManagedObjectList.Create(False); FPinnedObjects := TGCObjectSet.Create; FTempRoots := TGCObjectSet.Create; + FQueuedRoots := TGCObjectRefCounts.Create; + FKeptObjects := TGCObjectSet.Create; FRootObjects := TGCObjectSet.Create; FActiveRootStack := TGCManagedObjectList.Create(False); FAllocationsSinceLastGC := 0; @@ -344,6 +359,8 @@ destructor TGarbageCollector.Destroy; FManagedObjects.Free; FPinnedObjects.Free; FTempRoots.Free; + FQueuedRoots.Free; + FKeptObjects.Free; FRootObjects.Free; FActiveRootStack.Free; inherited; @@ -381,6 +398,10 @@ procedure TGarbageCollector.UnregisterObject( FPinnedObjects.Remove(AObject); if Assigned(FTempRoots) then FTempRoots.Remove(AObject); + if Assigned(FQueuedRoots) then + FQueuedRoots.Remove(AObject); + if Assigned(FKeptObjects) then + FKeptObjects.Remove(AObject); if Assigned(FRootObjects) then FRootObjects.Remove(AObject); ClearActiveRootEntries(AObject); @@ -437,6 +458,46 @@ function TGarbageCollector.IsTempRoot( Result := Assigned(AObject) and FTempRoots.ContainsKey(AObject); end; +procedure TGarbageCollector.AddQueuedRoot( + const AObject: TGCManagedObject); +var + Count: Integer; +begin + if not Assigned(AObject) then + Exit; + if FQueuedRoots.TryGetValue(AObject, Count) then + FQueuedRoots.AddOrSetValue(AObject, Count + 1) + else + FQueuedRoots.Add(AObject, 1); +end; + +procedure TGarbageCollector.RemoveQueuedRoot( + const AObject: TGCManagedObject); +var + Count: Integer; +begin + if not Assigned(AObject) then + Exit; + if not FQueuedRoots.TryGetValue(AObject, Count) then + Exit; + if Count <= 1 then + FQueuedRoots.Remove(AObject) + else + FQueuedRoots.AddOrSetValue(AObject, Count - 1); +end; + +procedure TGarbageCollector.AddKeptObject( + const AObject: TGCManagedObject); +begin + if Assigned(AObject) then + FKeptObjects.Add(AObject, True); +end; + +procedure TGarbageCollector.ClearKeptObjects; +begin + FKeptObjects.Clear; +end; + procedure TGarbageCollector.AddRootObject( const AObject: TGCManagedObject); begin @@ -464,6 +525,7 @@ procedure TGarbageCollector.PopActiveRoot; procedure TGarbageCollector.MarkRoots; var Pair: TGCObjectSet.TKeyValuePair; + QueuedPair: TGCObjectRefCounts.TKeyValuePair; I: Integer; begin for Pair in FPinnedObjects do @@ -472,6 +534,12 @@ procedure TGarbageCollector.MarkRoots; for Pair in FTempRoots do Pair.Key.MarkReferences; + for QueuedPair in FQueuedRoots do + QueuedPair.Key.MarkReferences; + + for Pair in FKeptObjects do + Pair.Key.MarkReferences; + for Pair in FRootObjects do Pair.Key.MarkReferences; @@ -553,53 +621,58 @@ procedure TGarbageCollector.Collect; MarkNs, SweepNs, TotalNs: Int64; {$ENDIF} begin - if FCollecting then Exit; - FCollecting := True; + EnterCriticalSection(GCCollectLock); try - BeforeCount := FManagedObjects.Count; - TGCManagedObject.AdvanceMark; - {$IFDEF GC_TIMING} - StartNs := GetNanoseconds; - {$ENDIF} - MarkRoots; - TraceWeakReferences; - SweepWeakReferences; - {$IFDEF GC_TIMING} - AfterMarkNs := GetNanoseconds; - {$ENDIF} - SweepObjects; - FAllocationsSinceLastGC := 0; - - // Adaptive threshold: next collection after allocating as many - // objects as survived, amortizing collection cost to O(1) per - // allocation. Small heaps keep the default minimum. - FGCThreshold := FManagedObjects.Count; - if FGCThreshold < DEFAULT_GC_THRESHOLD then - FGCThreshold := DEFAULT_GC_THRESHOLD; - - Inc(FTotalCollections); - {$IFDEF GC_TIMING} - EndNs := GetNanoseconds; - MarkNs := AfterMarkNs - StartNs; - SweepNs := EndNs - AfterMarkNs; - TotalNs := EndNs - StartNs; - FTotalMarkTimeNs := FTotalMarkTimeNs + MarkNs; - FTotalSweepTimeNs := FTotalSweepTimeNs + SweepNs; - FTotalGCTimeNs := FTotalGCTimeNs + TotalNs; - if MarkNs > FMaxMarkTimeNs then - FMaxMarkTimeNs := MarkNs; - if SweepNs > FMaxSweepTimeNs then - FMaxSweepTimeNs := SweepNs; - WriteLn(Format('[GC] Collect: mark=%s sweep=%s total=%s (%d before, %d after)', - [FormatDuration(MarkNs), FormatDuration(SweepNs), FormatDuration(TotalNs), - BeforeCount, FManagedObjects.Count])); - {$ENDIF} - {$IFDEF GC_DEBUG} - WriteLn(Format('[GC] Collect: %d -> %d objects (%d freed)', - [BeforeCount, FManagedObjects.Count, BeforeCount - FManagedObjects.Count])); - {$ENDIF} + if FCollecting then Exit; + FCollecting := True; + try + BeforeCount := FManagedObjects.Count; + TGCManagedObject.AdvanceMark; + {$IFDEF GC_TIMING} + StartNs := GetNanoseconds; + {$ENDIF} + MarkRoots; + TraceWeakReferences; + SweepWeakReferences; + {$IFDEF GC_TIMING} + AfterMarkNs := GetNanoseconds; + {$ENDIF} + SweepObjects; + FAllocationsSinceLastGC := 0; + + // Adaptive threshold: next collection after allocating as many + // objects as survived, amortizing collection cost to O(1) per + // allocation. Small heaps keep the default minimum. + FGCThreshold := FManagedObjects.Count; + if FGCThreshold < DEFAULT_GC_THRESHOLD then + FGCThreshold := DEFAULT_GC_THRESHOLD; + + Inc(FTotalCollections); + {$IFDEF GC_TIMING} + EndNs := GetNanoseconds; + MarkNs := AfterMarkNs - StartNs; + SweepNs := EndNs - AfterMarkNs; + TotalNs := EndNs - StartNs; + FTotalMarkTimeNs := FTotalMarkTimeNs + MarkNs; + FTotalSweepTimeNs := FTotalSweepTimeNs + SweepNs; + FTotalGCTimeNs := FTotalGCTimeNs + TotalNs; + if MarkNs > FMaxMarkTimeNs then + FMaxMarkTimeNs := MarkNs; + if SweepNs > FMaxSweepTimeNs then + FMaxSweepTimeNs := SweepNs; + WriteLn(Format('[GC] Collect: mark=%s sweep=%s total=%s (%d before, %d after)', + [FormatDuration(MarkNs), FormatDuration(SweepNs), FormatDuration(TotalNs), + BeforeCount, FManagedObjects.Count])); + {$ENDIF} + {$IFDEF GC_DEBUG} + WriteLn(Format('[GC] Collect: %d -> %d objects (%d freed)', + [BeforeCount, FManagedObjects.Count, BeforeCount - FManagedObjects.Count])); + {$ENDIF} + finally + FCollecting := False; + end; finally - FCollecting := False; + LeaveCriticalSection(GCCollectLock); end; end; @@ -632,64 +705,69 @@ procedure TGarbageCollector.CollectYoung(const AWatermark: Integer); Obj: TGCManagedObject; EffectiveWatermark: Integer; begin - if FCollecting then Exit; - FCollecting := True; + EnterCriticalSection(GCCollectLock); try - EffectiveWatermark := AWatermark; - if EffectiveWatermark < 0 then - EffectiveWatermark := 0; - if EffectiveWatermark > FManagedObjects.Count then - EffectiveWatermark := FManagedObjects.Count; - - TGCManagedObject.AdvanceMark; - - for I := 0 to EffectiveWatermark - 1 do - begin - Obj := FManagedObjects[I]; - if Assigned(Obj) then - Obj.GCMarked := True; - end; + if FCollecting then Exit; + FCollecting := True; + try + EffectiveWatermark := AWatermark; + if EffectiveWatermark < 0 then + EffectiveWatermark := 0; + if EffectiveWatermark > FManagedObjects.Count then + EffectiveWatermark := FManagedObjects.Count; + + TGCManagedObject.AdvanceMark; + + for I := 0 to EffectiveWatermark - 1 do + begin + Obj := FManagedObjects[I]; + if Assigned(Obj) then + Obj.GCMarked := True; + end; - MarkRoots; - TraceWeakReferences; - SweepWeakReferences; + MarkRoots; + TraceWeakReferences; + SweepWeakReferences; - Collected := 0; - WriteIdx := EffectiveWatermark; + Collected := 0; + WriteIdx := EffectiveWatermark; - for I := EffectiveWatermark to FManagedObjects.Count - 1 do - begin - Obj := FManagedObjects[I]; - if Obj = nil then - Continue; - if Obj.GCMarked then + for I := EffectiveWatermark to FManagedObjects.Count - 1 do begin - Obj.GCIndex := WriteIdx; - FManagedObjects[WriteIdx] := Obj; - Inc(WriteIdx); - end - else - begin - Dec(FBytesAllocated, Obj.InstanceSize); - Obj.GCIndex := -1; - Obj.Recycle; - Inc(Collected); + Obj := FManagedObjects[I]; + if Obj = nil then + Continue; + if Obj.GCMarked then + begin + Obj.GCIndex := WriteIdx; + FManagedObjects[WriteIdx] := Obj; + Inc(WriteIdx); + end + else + begin + Dec(FBytesAllocated, Obj.InstanceSize); + Obj.GCIndex := -1; + Obj.Recycle; + Inc(Collected); + end; end; - end; - FManagedObjects.Count := WriteIdx; - if FManagedObjects.Capacity > 4 * WriteIdx + 256 then - FManagedObjects.Capacity := WriteIdx + (WriteIdx div 2); - FAllocationsSinceLastGC := 0; - FTotalCollected := FTotalCollected + Collected; - Inc(FTotalCollections); - {$IFDEF GC_DEBUG} - WriteLn(Format('[GC] CollectYoung(wm=%d): %d total, %d young, %d freed, %d surviving', - [AWatermark, EffectiveWatermark + (FManagedObjects.Count - EffectiveWatermark) + Collected, - FManagedObjects.Count - EffectiveWatermark + Collected, Collected, FManagedObjects.Count])); - {$ENDIF} + FManagedObjects.Count := WriteIdx; + if FManagedObjects.Capacity > 4 * WriteIdx + 256 then + FManagedObjects.Capacity := WriteIdx + (WriteIdx div 2); + FAllocationsSinceLastGC := 0; + FTotalCollected := FTotalCollected + Collected; + Inc(FTotalCollections); + {$IFDEF GC_DEBUG} + WriteLn(Format('[GC] CollectYoung(wm=%d): %d total, %d young, %d freed, %d surviving', + [AWatermark, EffectiveWatermark + (FManagedObjects.Count - EffectiveWatermark) + Collected, + FManagedObjects.Count - EffectiveWatermark + Collected, Collected, FManagedObjects.Count])); + {$ENDIF} + finally + FCollecting := False; + end; finally - FCollecting := False; + LeaveCriticalSection(GCCollectLock); end; end; @@ -734,6 +812,10 @@ function TGarbageCollector.GetManagedObjectCount: Integer; end; initialization + InitCriticalSection(GCCollectLock); GCCurrentMark := 1; +finalization + DoneCriticalSection(GCCollectLock); + end. diff --git a/source/units/Goccia.MicrotaskQueue.pas b/source/units/Goccia.MicrotaskQueue.pas index 4423ff08c..24327aae2 100644 --- a/source/units/Goccia.MicrotaskQueue.pas +++ b/source/units/Goccia.MicrotaskQueue.pas @@ -22,7 +22,14 @@ TGocciaMicrotask = record TGocciaMicrotaskQueue = class private FQueue: TList; + FFinalizationQueue: TList; FHead: Integer; + FFinalizationHead: Integer; + procedure AddQueuedRoots(const AMicrotask: TGocciaMicrotask); + procedure RemoveQueuedRoots(const AMicrotask: TGocciaMicrotask); + procedure ExecuteTask(const ATask: TGocciaMicrotask); + procedure CompactQueueIfEmpty; + procedure CompactFinalizationQueueIfEmpty; public class function Instance: TGocciaMicrotaskQueue; class procedure Initialize; @@ -32,6 +39,7 @@ TGocciaMicrotaskQueue = class destructor Destroy; override; procedure Enqueue(const AMicrotask: TGocciaMicrotask); + procedure EnqueueFinalizationCleanup(const AMicrotask: TGocciaMicrotask); procedure DrainQueue; procedure ClearQueue; function HasPending: Boolean; @@ -73,26 +81,155 @@ class procedure TGocciaMicrotaskQueue.Shutdown; constructor TGocciaMicrotaskQueue.Create; begin FQueue := TList.Create; + FFinalizationQueue := TList.Create; FHead := 0; + FFinalizationHead := 0; end; destructor TGocciaMicrotaskQueue.Destroy; begin + FFinalizationQueue.Free; FQueue.Free; inherited; end; procedure TGocciaMicrotaskQueue.Enqueue(const AMicrotask: TGocciaMicrotask); begin + AddQueuedRoots(AMicrotask); FQueue.Add(AMicrotask); end; -procedure TGocciaMicrotaskQueue.DrainQueue; +procedure TGocciaMicrotaskQueue.EnqueueFinalizationCleanup( + const AMicrotask: TGocciaMicrotask); +begin + AddQueuedRoots(AMicrotask); + FFinalizationQueue.Add(AMicrotask); +end; + +procedure TGocciaMicrotaskQueue.AddQueuedRoots( + const AMicrotask: TGocciaMicrotask); +var + GC: TGarbageCollector; +begin + GC := TGarbageCollector.Instance; + if not Assigned(GC) then + Exit; + if Assigned(AMicrotask.Handler) then + GC.AddQueuedRoot(AMicrotask.Handler); + if Assigned(AMicrotask.Value) then + GC.AddQueuedRoot(AMicrotask.Value); + if Assigned(AMicrotask.ResultPromise) then + GC.AddQueuedRoot(AMicrotask.ResultPromise); +end; + +procedure TGocciaMicrotaskQueue.RemoveQueuedRoots( + const AMicrotask: TGocciaMicrotask); +var + GC: TGarbageCollector; +begin + GC := TGarbageCollector.Instance; + if not Assigned(GC) then + Exit; + if Assigned(AMicrotask.Handler) then + GC.RemoveQueuedRoot(AMicrotask.Handler); + if Assigned(AMicrotask.Value) then + GC.RemoveQueuedRoot(AMicrotask.Value); + if Assigned(AMicrotask.ResultPromise) then + GC.RemoveQueuedRoot(AMicrotask.ResultPromise); +end; + +procedure TGocciaMicrotaskQueue.ExecuteTask( + const ATask: TGocciaMicrotask); var - Task: TGocciaMicrotask; Promise: TGocciaPromiseValue; HandlerResult: TGocciaValue; CallArgs: TGocciaArgumentsCollection; +begin + Promise := TGocciaPromiseValue(ATask.ResultPromise); + + if Assigned(TGarbageCollector.Instance) then + begin + if Assigned(ATask.Handler) then + TGarbageCollector.Instance.AddTempRoot(ATask.Handler); + if Assigned(ATask.Value) then + TGarbageCollector.Instance.AddTempRoot(ATask.Value); + if Assigned(Promise) then + TGarbageCollector.Instance.AddTempRoot(Promise); + end; + + try + if Assigned(ATask.Handler) and ATask.Handler.IsCallable then + begin + CallArgs := TGocciaArgumentsCollection.Create([ATask.Value]); + try + try + HandlerResult := TGocciaFunctionBase(ATask.Handler).Call( + CallArgs, TGocciaUndefinedLiteralValue.UndefinedValue); + if Assigned(Promise) then + Promise.Resolve(HandlerResult); + except + on E: EGocciaBytecodeThrow do + if Assigned(Promise) then + Promise.Reject(E.ThrownValue) + else + raise; + on E: TGocciaThrowValue do + if Assigned(Promise) then + Promise.Reject(E.Value) + else + raise; + end; + finally + CallArgs.Free; + end; + end + else + begin + if Assigned(Promise) then + begin + case ATask.ReactionType of + prtFulfill: Promise.Resolve(ATask.Value); + prtReject: Promise.Reject(ATask.Value); + prtThenableResolve: + if ATask.Value is TGocciaPromiseValue then + Promise.SubscribeTo(TGocciaPromiseValue(ATask.Value)); + end; + end; + end; + finally + if Assigned(TGarbageCollector.Instance) then + begin + if Assigned(ATask.Handler) then + TGarbageCollector.Instance.RemoveTempRoot(ATask.Handler); + if Assigned(ATask.Value) then + TGarbageCollector.Instance.RemoveTempRoot(ATask.Value); + if Assigned(Promise) then + TGarbageCollector.Instance.RemoveTempRoot(Promise); + end; + end; +end; + +procedure TGocciaMicrotaskQueue.CompactQueueIfEmpty; +begin + if FHead >= FQueue.Count then + begin + FQueue.Clear; + FHead := 0; + end; +end; + +procedure TGocciaMicrotaskQueue.CompactFinalizationQueueIfEmpty; +begin + if FFinalizationHead >= FFinalizationQueue.Count then + begin + FFinalizationQueue.Clear; + FFinalizationHead := 0; + end; +end; + +procedure TGocciaMicrotaskQueue.DrainQueue; +var + Task: TGocciaMicrotask; begin // Advance the head index BEFORE running each task so that recursive // DrainQueue calls (e.g. when a handler awaits a settled promise, which @@ -109,75 +246,39 @@ procedure TGocciaMicrotaskQueue.DrainQueue; // microtask bursts (Promise-heavy fan-outs, await-loops in async iterators). // Once FHead catches up to Count we compact by clearing the underlying list // so the buffer does not grow unboundedly across drains. - while FHead < FQueue.Count do + while (FHead < FQueue.Count) or + (FFinalizationHead < FFinalizationQueue.Count) do begin - CheckExecutionTimeout; - CheckInstructionLimit; - Task := FQueue[FHead]; - Inc(FHead); - - Promise := TGocciaPromiseValue(Task.ResultPromise); - - if Assigned(TGarbageCollector.Instance) then + while FHead < FQueue.Count do begin - if Assigned(Task.Handler) then - TGarbageCollector.Instance.AddTempRoot(Task.Handler); - if Assigned(Task.Value) then - TGarbageCollector.Instance.AddTempRoot(Task.Value); - if Assigned(Promise) then - TGarbageCollector.Instance.AddTempRoot(Promise); + CheckExecutionTimeout; + CheckInstructionLimit; + Task := FQueue[FHead]; + Inc(FHead); + try + ExecuteTask(Task); + finally + RemoveQueuedRoots(Task); + if Assigned(TGarbageCollector.Instance) then + TGarbageCollector.Instance.ClearKeptObjects; + end; end; + CompactQueueIfEmpty; - try - if Assigned(Task.Handler) and Task.Handler.IsCallable then - begin - CallArgs := TGocciaArgumentsCollection.Create([Task.Value]); - try - try - HandlerResult := TGocciaFunctionBase(Task.Handler).Call( - CallArgs, TGocciaUndefinedLiteralValue.UndefinedValue); - if Assigned(Promise) then - Promise.Resolve(HandlerResult); - except - on E: EGocciaBytecodeThrow do - if Assigned(Promise) then - Promise.Reject(E.ThrownValue); - on E: TGocciaThrowValue do - if Assigned(Promise) then - Promise.Reject(E.Value); - // TODO: Per HTML spec, queueMicrotask callback errors should be - // "reported" (Node.js: uncaughtException, browsers: global error - // event). Currently silently discarded because GocciaScript has no - // process-level error reporting. Add reporting when/if an error - // event mechanism is implemented. - end; - finally - CallArgs.Free; - end; - end - else - begin - if Assigned(Promise) then - begin - case Task.ReactionType of - prtFulfill: Promise.Resolve(Task.Value); - prtReject: Promise.Reject(Task.Value); - prtThenableResolve: - if Task.Value is TGocciaPromiseValue then - Promise.SubscribeTo(TGocciaPromiseValue(Task.Value)); - end; - end; - end; - finally - if Assigned(TGarbageCollector.Instance) then - begin - if Assigned(Task.Handler) then - TGarbageCollector.Instance.RemoveTempRoot(Task.Handler); - if Assigned(Task.Value) then - TGarbageCollector.Instance.RemoveTempRoot(Task.Value); - if Assigned(Promise) then - TGarbageCollector.Instance.RemoveTempRoot(Promise); + if FFinalizationHead < FFinalizationQueue.Count then + begin + CheckExecutionTimeout; + CheckInstructionLimit; + Task := FFinalizationQueue[FFinalizationHead]; + Inc(FFinalizationHead); + try + ExecuteTask(Task); + finally + RemoveQueuedRoots(Task); + if Assigned(TGarbageCollector.Instance) then + TGarbageCollector.Instance.ClearKeptObjects; end; + CompactFinalizationQueueIfEmpty; end; end; @@ -186,10 +287,7 @@ procedure TGocciaMicrotaskQueue.DrainQueue; // the underlying TList array) any longer than necessary // and so FHead/Count cannot drift unbounded across many drains. if FHead >= FQueue.Count then - begin - FQueue.Clear; - FHead := 0; - end; + CompactQueueIfEmpty; end; procedure TGocciaMicrotaskQueue.ClearQueue; @@ -197,20 +295,26 @@ procedure TGocciaMicrotaskQueue.ClearQueue; I: Integer; Task: TGocciaMicrotask; begin - if Assigned(TGarbageCollector.Instance) then - for I := FHead to FQueue.Count - 1 do - begin - Task := FQueue[I]; - if Assigned(Task.Handler) then - TGarbageCollector.Instance.RemoveTempRoot(Task.Handler); - end; + for I := FHead to FQueue.Count - 1 do + begin + Task := FQueue[I]; + RemoveQueuedRoots(Task); + end; + for I := FFinalizationHead to FFinalizationQueue.Count - 1 do + begin + Task := FFinalizationQueue[I]; + RemoveQueuedRoots(Task); + end; FQueue.Clear; + FFinalizationQueue.Clear; FHead := 0; + FFinalizationHead := 0; end; function TGocciaMicrotaskQueue.HasPending: Boolean; begin - Result := FHead < FQueue.Count; + Result := (FHead < FQueue.Count) or + (FFinalizationHead < FFinalizationQueue.Count); end; end. diff --git a/source/units/Goccia.Threading.pas b/source/units/Goccia.Threading.pas index fb02cb211..cf6ee7d7a 100644 --- a/source/units/Goccia.Threading.pas +++ b/source/units/Goccia.Threading.pas @@ -205,12 +205,9 @@ procedure InitThreadRuntime(AEnableCoverage: Boolean; AMaxBytes: Int64); begin GIsWorkerThread := True; TGarbageCollector.Initialize; - // Disable automatic GC on worker threads. Shared immutable objects - // (primitive singletons, shared prototypes) have a single FGCMark field - // written by the mark phase — running GC on multiple threads would race - // on that field. Each worker runs a single file and then shuts down, so - // skipping collection is acceptable: all objects are freed in bulk when - // the thread-local GC is destroyed. + // Disable automatic GC on worker threads. Explicit Goccia.gc() still + // runs under the global collector lock, but workers otherwise reclaim + // their thread-local heaps in bulk when the runtime shuts down. TGarbageCollector.Instance.Enabled := False; // Propagate the memory ceiling from the main thread so that --max-memory // is honoured on workers. Without this, workers use the auto-detected diff --git a/source/units/Goccia.Values.ClassValue.pas b/source/units/Goccia.Values.ClassValue.pas index 8593dc594..49e7af2bd 100644 --- a/source/units/Goccia.Values.ClassValue.pas +++ b/source/units/Goccia.Values.ClassValue.pas @@ -181,6 +181,16 @@ TGocciaWeakSetClassValue = class(TGocciaClassValue) function CreateNativeInstance(const AArguments: TGocciaArgumentsCollection): TGocciaObjectValue; override; end; + TGocciaWeakRefClassValue = class(TGocciaClassValue) + function CreateNativeInstance(const AArguments: TGocciaArgumentsCollection): TGocciaObjectValue; override; + function GetClassLength: Integer; override; + end; + + TGocciaFinalizationRegistryClassValue = class(TGocciaClassValue) + function CreateNativeInstance(const AArguments: TGocciaArgumentsCollection): TGocciaObjectValue; override; + function GetClassLength: Integer; override; + end; + TGocciaStringClassValue = class(TGocciaClassValue) function CreateNativeInstance(const AArguments: TGocciaArgumentsCollection): TGocciaObjectValue; override; // ECMAScript 22.1.1.1: String constructor length is 1. @@ -302,6 +312,7 @@ implementation Goccia.Values.AutoAccessor, Goccia.Values.ClassHelper, Goccia.Values.ErrorHelper, + Goccia.Values.FinalizationRegistryValue, Goccia.Values.HeadersValue, Goccia.Values.MapValue, Goccia.Values.NativeFunction, @@ -314,6 +325,7 @@ implementation Goccia.Values.URLSearchParamsValue, Goccia.Values.URLValue, Goccia.Values.WeakMapValue, + Goccia.Values.WeakRefValue, Goccia.Values.WeakSetValue; // SetDefaultPrototype / PatchDefaultPrototype previously cached the @@ -1415,6 +1427,30 @@ function TGocciaWeakSetClassValue.CreateNativeInstance(const AArguments: TGoccia Result := TGocciaWeakSetValue.Create; end; +{ TGocciaWeakRefClassValue } + +function TGocciaWeakRefClassValue.CreateNativeInstance(const AArguments: TGocciaArgumentsCollection): TGocciaObjectValue; +begin + Result := TGocciaWeakRefValue.Create; +end; + +function TGocciaWeakRefClassValue.GetClassLength: Integer; +begin + Result := 1; +end; + +{ TGocciaFinalizationRegistryClassValue } + +function TGocciaFinalizationRegistryClassValue.CreateNativeInstance(const AArguments: TGocciaArgumentsCollection): TGocciaObjectValue; +begin + Result := TGocciaFinalizationRegistryValue.Create; +end; + +function TGocciaFinalizationRegistryClassValue.GetClassLength: Integer; +begin + Result := 1; +end; + { TGocciaArrayBufferClassValue } function TGocciaArrayBufferClassValue.CreateNativeInstance(const AArguments: TGocciaArgumentsCollection): TGocciaObjectValue; diff --git a/source/units/Goccia.Values.FinalizationRegistryValue.pas b/source/units/Goccia.Values.FinalizationRegistryValue.pas new file mode 100644 index 000000000..ae5f22b15 --- /dev/null +++ b/source/units/Goccia.Values.FinalizationRegistryValue.pas @@ -0,0 +1,276 @@ +unit Goccia.Values.FinalizationRegistryValue; + +{$I Goccia.inc} + +interface + +uses + Generics.Collections, + + Goccia.Arguments.Collection, + Goccia.ObjectModel, + Goccia.SharedPrototype, + Goccia.Values.ClassValue, + Goccia.Values.ObjectValue, + Goccia.Values.Primitives; + +type + TGocciaFinalizationRegistryCell = record + Target: TGocciaValue; + HeldValue: TGocciaValue; + UnregisterToken: TGocciaValue; + HasUnregisterToken: Boolean; + end; + + TGocciaFinalizationRegistryValue = class(TGocciaInstanceValue) + private + FCleanupCallback: TGocciaValue; + FCells: TList; + procedure EnqueueCleanup(const AHeldValue: TGocciaValue); + procedure InitializePrototype; + public + function FinalizationRegistryRegister(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; + function FinalizationRegistryUnregister(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; + + constructor Create(const AClass: TGocciaClassValue = nil); + destructor Destroy; override; + + function ToStringTag: string; override; + + procedure InitializeNativeFromArguments(const AArguments: TGocciaArgumentsCollection); override; + procedure MarkReferences; override; + procedure SweepWeakReferences; override; + + class procedure ExposePrototype(const AConstructor: TGocciaValue); + + property CleanupCallback: TGocciaValue read FCleanupCallback; + end; + +implementation + +uses + Goccia.Arithmetic, + Goccia.Constants.ConstructorNames, + Goccia.Error.Messages, + Goccia.Error.Suggestions, + Goccia.GarbageCollector, + Goccia.MicrotaskQueue, + Goccia.Realm, + Goccia.Values.ErrorHelper, + Goccia.Values.ObjectPropertyDescriptor, + Goccia.Values.SymbolValue, + Goccia.Values.WeakReferenceSupport; + +var + GFinalizationRegistrySharedSlot: TGocciaRealmOwnedSlotId; + +threadvar + FPrototypeMembers: TArray; + +function GetFinalizationRegistryShared: TGocciaSharedPrototype; inline; +begin + if Assigned(CurrentRealm) then + Result := TGocciaSharedPrototype(CurrentRealm.GetOwnedSlot(GFinalizationRegistrySharedSlot)) + else + Result := nil; +end; + +constructor TGocciaFinalizationRegistryValue.Create( + const AClass: TGocciaClassValue); +var + Shared: TGocciaSharedPrototype; +begin + inherited Create(AClass); + FCleanupCallback := nil; + FCells := TList.Create; + InitializePrototype; + Shared := GetFinalizationRegistryShared; + if not Assigned(AClass) and Assigned(Shared) then + FPrototype := Shared.Prototype; +end; + +destructor TGocciaFinalizationRegistryValue.Destroy; +begin + FCells.Free; + inherited; +end; + +procedure TGocciaFinalizationRegistryValue.InitializePrototype; +var + Members: TGocciaMemberCollection; + Shared: TGocciaSharedPrototype; +begin + if not Assigned(CurrentRealm) then Exit; + if Assigned(GetFinalizationRegistryShared) then Exit; + + Shared := TGocciaSharedPrototype.Create(Self); + CurrentRealm.SetOwnedSlot(GFinalizationRegistrySharedSlot, Shared); + if Length(FPrototypeMembers) = 0 then + begin + Members := TGocciaMemberCollection.Create; + try + Members.AddNamedMethod('register', FinalizationRegistryRegister, 2, gmkPrototypeMethod, [gmfNoFunctionPrototype, gmfNotConstructable]); + Members.AddNamedMethod('unregister', FinalizationRegistryUnregister, 1, gmkPrototypeMethod, [gmfNoFunctionPrototype, gmfNotConstructable]); + Members.AddSymbolDataProperty( + TGocciaSymbolValue.WellKnownToStringTag, + TGocciaStringLiteralValue.Create(CONSTRUCTOR_FINALIZATION_REGISTRY), + [pfConfigurable]); + FPrototypeMembers := Members.ToDefinitions; + finally + Members.Free; + end; + end; + RegisterMemberDefinitions(Shared.Prototype, FPrototypeMembers); +end; + +class procedure TGocciaFinalizationRegistryValue.ExposePrototype( + const AConstructor: TGocciaValue); +var + Shared: TGocciaSharedPrototype; +begin + Shared := GetFinalizationRegistryShared; + if not Assigned(Shared) then + begin + TGocciaFinalizationRegistryValue.Create; + Shared := GetFinalizationRegistryShared; + end; + if Assigned(Shared) then + ExposeSharedPrototypeOnConstructor(Shared, AConstructor); +end; + +function TGocciaFinalizationRegistryValue.ToStringTag: string; +begin + Result := CONSTRUCTOR_FINALIZATION_REGISTRY; +end; + +// ES2026 §26.2.1.1 FinalizationRegistry(cleanupCallback) +procedure TGocciaFinalizationRegistryValue.InitializeNativeFromArguments( + const AArguments: TGocciaArgumentsCollection); +begin + FCleanupCallback := AArguments.GetElement(0); + if not Assigned(FCleanupCallback) or not FCleanupCallback.IsCallable then + ThrowTypeError(SErrorFinalizationRegistryCleanupNotCallable, + SSuggestCallbackRequired); +end; + +procedure TGocciaFinalizationRegistryValue.MarkReferences; +var + I: Integer; +begin + if GCMarked then Exit; + inherited; + if Assigned(FCleanupCallback) then + FCleanupCallback.MarkReferences; + for I := 0 to FCells.Count - 1 do + if Assigned(FCells[I].HeldValue) then + FCells[I].HeldValue.MarkReferences; +end; + +procedure TGocciaFinalizationRegistryValue.EnqueueCleanup( + const AHeldValue: TGocciaValue); +var + Task: TGocciaMicrotask; +begin + if not Assigned(TGocciaMicrotaskQueue.Instance) then + Exit; + Task.Handler := FCleanupCallback; + Task.ResultPromise := nil; + Task.Value := AHeldValue; + Task.ReactionType := prtFulfill; + TGocciaMicrotaskQueue.Instance.EnqueueFinalizationCleanup(Task); +end; + +procedure TGocciaFinalizationRegistryValue.SweepWeakReferences; +var + I: Integer; + Cell: TGocciaFinalizationRegistryCell; +begin + for I := FCells.Count - 1 downto 0 do + begin + Cell := FCells[I]; + if Assigned(Cell.Target) and not Cell.Target.GCMarked then + begin + EnqueueCleanup(Cell.HeldValue); + FCells.Delete(I); + end; + end; +end; + +// ES2026 §26.2.3.2 FinalizationRegistry.prototype.register(target, heldValue [ , unregisterToken ]) +function TGocciaFinalizationRegistryValue.FinalizationRegistryRegister( + const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; +var + Registry: TGocciaFinalizationRegistryValue; + Target, HeldValue, UnregisterToken: TGocciaValue; + Cell: TGocciaFinalizationRegistryCell; +begin + if not (AThisValue is TGocciaFinalizationRegistryValue) then + ThrowTypeError(SErrorFinalizationRegistryRegisterNonRegistry, + SSuggestFinalizationRegistryThisType); + + Registry := TGocciaFinalizationRegistryValue(AThisValue); + Target := AArgs.GetElement(0); + RequireCanBeHeldWeakly(Target, 'FinalizationRegistry.prototype.register'); + HeldValue := AArgs.GetElement(1); + if IsSameValue(Target, HeldValue) then + ThrowTypeError(SErrorFinalizationRegistryTargetHeldValueSame, + SSuggestFinalizationRegistryHeldValue); + + UnregisterToken := AArgs.GetElement(2); + Cell.Target := Target; + Cell.HeldValue := HeldValue; + Cell.UnregisterToken := nil; + Cell.HasUnregisterToken := False; + + if not (UnregisterToken is TGocciaUndefinedLiteralValue) then + begin + RequireCanBeHeldWeakly(UnregisterToken, + 'FinalizationRegistry.prototype.register'); + Cell.UnregisterToken := UnregisterToken; + Cell.HasUnregisterToken := True; + end; + + Registry.FCells.Add(Cell); + if Assigned(TGarbageCollector.Instance) then + TGarbageCollector.Instance.AddKeptObject(Target); + Result := TGocciaUndefinedLiteralValue.UndefinedValue; +end; + +// ES2026 §26.2.3.3 FinalizationRegistry.prototype.unregister(unregisterToken) +function TGocciaFinalizationRegistryValue.FinalizationRegistryUnregister( + const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; +var + Registry: TGocciaFinalizationRegistryValue; + UnregisterToken: TGocciaValue; + I: Integer; + Removed: Boolean; +begin + if not (AThisValue is TGocciaFinalizationRegistryValue) then + ThrowTypeError(SErrorFinalizationRegistryUnregisterNonRegistry, + SSuggestFinalizationRegistryThisType); + + Registry := TGocciaFinalizationRegistryValue(AThisValue); + UnregisterToken := AArgs.GetElement(0); + RequireCanBeHeldWeakly(UnregisterToken, + 'FinalizationRegistry.prototype.unregister'); + + Removed := False; + for I := Registry.FCells.Count - 1 downto 0 do + if Registry.FCells[I].HasUnregisterToken and + IsSameValue(Registry.FCells[I].UnregisterToken, UnregisterToken) then + begin + Registry.FCells.Delete(I); + Removed := True; + end; + + if Removed then + Result := TGocciaBooleanLiteralValue.TrueValue + else + Result := TGocciaBooleanLiteralValue.FalseValue; +end; + +initialization + GFinalizationRegistrySharedSlot := + RegisterRealmOwnedSlot('FinalizationRegistry.shared'); + +end. diff --git a/source/units/Goccia.Values.NativeFunction.pas b/source/units/Goccia.Values.NativeFunction.pas index 5951645c4..8d914ddf2 100644 --- a/source/units/Goccia.Values.NativeFunction.pas +++ b/source/units/Goccia.Values.NativeFunction.pas @@ -21,6 +21,7 @@ TGocciaNativeFunctionValue = class(TGocciaFunctionBase) FName: string; FArity: Integer; FNotConstructable: Boolean; + FCapturedRoot: TGocciaValue; protected function GetFunctionLength: Integer; override; function GetFunctionName: string; override; @@ -32,11 +33,13 @@ TGocciaNativeFunctionValue = class(TGocciaFunctionBase) function Call(const AArguments: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; override; function Construct(const AArguments: TGocciaArgumentsCollection; const ANewTarget: TGocciaValue): TGocciaValue; function IsConstructable: Boolean; override; + procedure MarkReferences; override; property NativeFunction: TGocciaNativeFunctionCallback read FFunction; property ConstructCallback: TGocciaNativeConstructorCallback read FConstructCallback write FConstructCallback; property Name: string read FName; property Arity: Integer read FArity; property NotConstructable: Boolean read FNotConstructable write FNotConstructable; + property CapturedRoot: TGocciaValue read FCapturedRoot write FCapturedRoot; end; @@ -104,6 +107,14 @@ function TGocciaNativeFunctionValue.IsConstructable: Boolean; Result := not FNotConstructable; end; +procedure TGocciaNativeFunctionValue.MarkReferences; +begin + if GCMarked then Exit; + inherited; + if Assigned(FCapturedRoot) then + FCapturedRoot.MarkReferences; +end; + function TGocciaNativeFunctionValue.GetFunctionLength: Integer; begin // -1 means variadic, report 0 for length per ECMAScript spec diff --git a/source/units/Goccia.Values.PromiseValue.pas b/source/units/Goccia.Values.PromiseValue.pas index 33e43a7d1..a153743e2 100644 --- a/source/units/Goccia.Values.PromiseValue.pas +++ b/source/units/Goccia.Values.PromiseValue.pas @@ -366,6 +366,8 @@ procedure TGocciaPromiseValue.Resolve(const AValue: TGocciaValue); FAlreadyResolved := False; ResolveFn := TGocciaNativeFunctionValue.CreateWithoutPrototype(DoResolve, 'resolve', 1); RejectFn := TGocciaNativeFunctionValue.CreateWithoutPrototype(DoReject, 'reject', 1); + ResolveFn.CapturedRoot := Self; + RejectFn.CapturedRoot := Self; ThenArgs := TGocciaArgumentsCollection.Create([ResolveFn, RejectFn]); GC := TGarbageCollector.Instance; try diff --git a/source/units/Goccia.Values.WeakRefValue.pas b/source/units/Goccia.Values.WeakRefValue.pas new file mode 100644 index 000000000..32034d0da --- /dev/null +++ b/source/units/Goccia.Values.WeakRefValue.pas @@ -0,0 +1,167 @@ +unit Goccia.Values.WeakRefValue; + +{$I Goccia.inc} + +interface + +uses + Goccia.Arguments.Collection, + Goccia.ObjectModel, + Goccia.SharedPrototype, + Goccia.Values.ClassValue, + Goccia.Values.ObjectValue, + Goccia.Values.Primitives; + +type + TGocciaWeakRefValue = class(TGocciaInstanceValue) + private + FTarget: TGocciaValue; + procedure InitializePrototype; + public + function WeakRefDeref(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; + + constructor Create(const AClass: TGocciaClassValue = nil); + + function ToStringTag: string; override; + + procedure InitializeNativeFromArguments(const AArguments: TGocciaArgumentsCollection); override; + procedure MarkReferences; override; + procedure SweepWeakReferences; override; + + class procedure ExposePrototype(const AConstructor: TGocciaValue); + + property Target: TGocciaValue read FTarget; + end; + +implementation + +uses + Goccia.Constants.ConstructorNames, + Goccia.Error.Messages, + Goccia.Error.Suggestions, + Goccia.GarbageCollector, + Goccia.Realm, + Goccia.Values.ErrorHelper, + Goccia.Values.ObjectPropertyDescriptor, + Goccia.Values.SymbolValue, + Goccia.Values.WeakReferenceSupport; + +var + GWeakRefSharedSlot: TGocciaRealmOwnedSlotId; + +threadvar + FPrototypeMembers: TArray; + +function GetWeakRefShared: TGocciaSharedPrototype; inline; +begin + if Assigned(CurrentRealm) then + Result := TGocciaSharedPrototype(CurrentRealm.GetOwnedSlot(GWeakRefSharedSlot)) + else + Result := nil; +end; + +constructor TGocciaWeakRefValue.Create(const AClass: TGocciaClassValue); +var + Shared: TGocciaSharedPrototype; +begin + inherited Create(AClass); + FTarget := nil; + InitializePrototype; + Shared := GetWeakRefShared; + if not Assigned(AClass) and Assigned(Shared) then + FPrototype := Shared.Prototype; +end; + +procedure TGocciaWeakRefValue.InitializePrototype; +var + Members: TGocciaMemberCollection; + Shared: TGocciaSharedPrototype; +begin + if not Assigned(CurrentRealm) then Exit; + if Assigned(GetWeakRefShared) then Exit; + + Shared := TGocciaSharedPrototype.Create(Self); + CurrentRealm.SetOwnedSlot(GWeakRefSharedSlot, Shared); + if Length(FPrototypeMembers) = 0 then + begin + Members := TGocciaMemberCollection.Create; + try + Members.AddNamedMethod('deref', WeakRefDeref, 0, gmkPrototypeMethod, [gmfNoFunctionPrototype, gmfNotConstructable]); + Members.AddSymbolDataProperty( + TGocciaSymbolValue.WellKnownToStringTag, + TGocciaStringLiteralValue.Create(CONSTRUCTOR_WEAK_REF), + [pfConfigurable]); + FPrototypeMembers := Members.ToDefinitions; + finally + Members.Free; + end; + end; + RegisterMemberDefinitions(Shared.Prototype, FPrototypeMembers); +end; + +class procedure TGocciaWeakRefValue.ExposePrototype( + const AConstructor: TGocciaValue); +var + Shared: TGocciaSharedPrototype; +begin + Shared := GetWeakRefShared; + if not Assigned(Shared) then + begin + TGocciaWeakRefValue.Create; + Shared := GetWeakRefShared; + end; + if Assigned(Shared) then + ExposeSharedPrototypeOnConstructor(Shared, AConstructor); +end; + +function TGocciaWeakRefValue.ToStringTag: string; +begin + Result := CONSTRUCTOR_WEAK_REF; +end; + +// ES2026 §26.1.1.1 WeakRef(target) +procedure TGocciaWeakRefValue.InitializeNativeFromArguments( + const AArguments: TGocciaArgumentsCollection); +begin + FTarget := AArguments.GetElement(0); + RequireCanBeHeldWeakly(FTarget, CONSTRUCTOR_WEAK_REF); + if Assigned(TGarbageCollector.Instance) then + TGarbageCollector.Instance.AddKeptObject(FTarget); +end; + +procedure TGocciaWeakRefValue.MarkReferences; +begin + if GCMarked then Exit; + inherited; +end; + +procedure TGocciaWeakRefValue.SweepWeakReferences; +begin + if Assigned(FTarget) and not FTarget.GCMarked then + FTarget := nil; +end; + +// ES2026 §26.1.3.2 WeakRef.prototype.deref() +function TGocciaWeakRefValue.WeakRefDeref( + const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; +var + WeakRef: TGocciaWeakRefValue; +begin + if not (AThisValue is TGocciaWeakRefValue) then + ThrowTypeError(SErrorWeakRefDerefNonWeakRef, SSuggestWeakRefThisType); + + WeakRef := TGocciaWeakRefValue(AThisValue); + if Assigned(WeakRef.FTarget) then + begin + if Assigned(TGarbageCollector.Instance) then + TGarbageCollector.Instance.AddKeptObject(WeakRef.FTarget); + Exit(WeakRef.FTarget); + end; + + Result := TGocciaUndefinedLiteralValue.UndefinedValue; +end; + +initialization + GWeakRefSharedSlot := RegisterRealmOwnedSlot('WeakRef.shared'); + +end. diff --git a/tests/built-ins/FinalizationRegistry/constructor.js b/tests/built-ins/FinalizationRegistry/constructor.js new file mode 100644 index 000000000..aa575ed05 --- /dev/null +++ b/tests/built-ins/FinalizationRegistry/constructor.js @@ -0,0 +1,56 @@ +/*--- +description: FinalizationRegistry constructor validates cleanup callbacks and exposes the expected shape +features: [FinalizationRegistry] +---*/ + +test("FinalizationRegistry constructor requires new", () => { + expect(() => FinalizationRegistry(() => {})).toThrow(TypeError); +}); + +test("FinalizationRegistry constructor accepts callable cleanup callbacks", () => { + const registry = new FinalizationRegistry(() => {}); + expect(registry instanceof FinalizationRegistry).toBe(true); +}); + +test("FinalizationRegistry constructor rejects non-callable cleanup callbacks", () => { + expect(() => new FinalizationRegistry()).toThrow(TypeError); + expect(() => new FinalizationRegistry(undefined)).toThrow(TypeError); + expect(() => new FinalizationRegistry(null)).toThrow(TypeError); + expect(() => new FinalizationRegistry({})).toThrow(TypeError); + expect(() => new FinalizationRegistry(1)).toThrow(TypeError); + expect(() => new FinalizationRegistry("callback")).toThrow(TypeError); +}); + +test("FinalizationRegistry.length is 1 with spec descriptor", () => { + expect(FinalizationRegistry.length).toBe(1); + const descriptor = Object.getOwnPropertyDescriptor(FinalizationRegistry, "length"); + expect(descriptor.value).toBe(1); + expect(descriptor.writable).toBe(false); + expect(descriptor.enumerable).toBe(false); + expect(descriptor.configurable).toBe(true); +}); + +test("FinalizationRegistry.prototype.constructor is FinalizationRegistry", () => { + expect(FinalizationRegistry.prototype.constructor).toBe(FinalizationRegistry); + expect(new FinalizationRegistry(() => {}).constructor).toBe(FinalizationRegistry); +}); + +test("FinalizationRegistry exposes only the standard prototype methods", () => { + expect(typeof FinalizationRegistry.prototype.register).toBe("function"); + expect(typeof FinalizationRegistry.prototype.unregister).toBe("function"); + expect(FinalizationRegistry.prototype.cleanupSome).toBe(undefined); +}); + +test("FinalizationRegistry.prototype methods are not constructors", () => { + const isConstructor = (fn) => { + try { + new fn(); + return true; + } catch (_) { + return false; + } + }; + + expect(isConstructor(FinalizationRegistry.prototype.register)).toBe(false); + expect(isConstructor(FinalizationRegistry.prototype.unregister)).toBe(false); +}); diff --git a/tests/built-ins/FinalizationRegistry/gc.js b/tests/built-ins/FinalizationRegistry/gc.js new file mode 100644 index 000000000..997ba8a20 --- /dev/null +++ b/tests/built-ins/FinalizationRegistry/gc.js @@ -0,0 +1,185 @@ +/*--- +description: FinalizationRegistry integrates with Goccia.gc and the finalization cleanup queue +features: [FinalizationRegistry, Goccia, queueMicrotask] +---*/ + +const hasGoccia = typeof Goccia !== "undefined"; + +describe.runIf(hasGoccia)("FinalizationRegistry GC behavior", () => { + test("cleanup is not synchronous in Goccia.gc and runs after normal microtasks", () => { + const log = []; + let done; + const registry = new FinalizationRegistry((held) => { + log.push("finalizer:" + held); + done(log.slice()); + }); + globalThis.__finalizationRegistryKeep = registry; + + const result = new Promise((resolve) => { + done = resolve; + }); + + queueMicrotask(() => { + let target = {}; + registry.register(target, "held"); + target = null; + }); + queueMicrotask(() => { + Goccia.gc(); + }); + queueMicrotask(() => { + Goccia.gc(); + log.push("after-gc"); + queueMicrotask(() => log.push("microtask")); + expect(log).toEqual(["after-gc"]); + }); + + return result.then((finalLog) => { + globalThis.__finalizationRegistryKeep = undefined; + expect(registry.unregister({})).toBe(false); + expect(finalLog).toEqual(["after-gc", "microtask", "finalizer:held"]); + }); + }); + + test("held value is delivered to cleanup callback", () => { + const held = { marker: "held" }; + let done; + const registry = new FinalizationRegistry((value) => { + done(value); + }); + globalThis.__finalizationRegistryKeep = registry; + + const result = new Promise((resolve) => { + done = resolve; + }); + + queueMicrotask(() => { + let target = {}; + registry.register(target, held); + target = null; + }); + queueMicrotask(() => { + Goccia.gc(); + }); + queueMicrotask(() => { + Goccia.gc(); + }); + + return result.then((value) => { + globalThis.__finalizationRegistryKeep = undefined; + expect(registry.unregister({})).toBe(false); + expect(value).toBe(held); + expect(value.marker).toBe("held"); + }); + }); + + test("unregister prevents queued cleanup for the removed cell", () => { + const log = []; + const token = {}; + const registry = new FinalizationRegistry((held) => { + log.push(held); + }); + + const result = new Promise((resolve) => { + queueMicrotask(() => { + let target = {}; + registry.register(target, "held", token); + target = null; + }); + queueMicrotask(() => { + expect(registry.unregister(token)).toBe(true); + Goccia.gc(); + }); + queueMicrotask(() => { + Goccia.gc(); + }); + queueMicrotask(() => { + resolve(log.slice()); + }); + }); + + return result.then((finalLog) => { + expect(finalLog).toEqual([]); + }); + }); + + test("unregister does not remove cells without a matching token", () => { + const log = []; + const token = {}; + let done; + const registry = new FinalizationRegistry((held) => { + log.push(held); + done(log.slice()); + }); + globalThis.__finalizationRegistryKeep = registry; + + const result = new Promise((resolve) => { + done = resolve; + }); + + queueMicrotask(() => { + let target = {}; + registry.register(target, "held"); + target = null; + }); + queueMicrotask(() => { + expect(registry.unregister(token)).toBe(false); + Goccia.gc(); + }); + queueMicrotask(() => { + Goccia.gc(); + }); + + return result.then((finalLog) => { + globalThis.__finalizationRegistryKeep = undefined; + expect(registry.unregister({})).toBe(false); + expect(finalLog).toEqual(["held"]); + }); + }); + + test("cleanup jobs enqueue microtasks before the next cleanup job runs", () => { + const log = []; + let cleanupCount = 0; + let done; + const result = new Promise((resolve) => { + done = resolve; + }); + const registry = new FinalizationRegistry((held) => { + log.push("finalizer:" + held); + queueMicrotask(() => { + log.push("microtask:" + held); + cleanupCount = cleanupCount + 1; + if (cleanupCount === 2) { + done(log.slice()); + } + }); + }); + globalThis.__finalizationRegistryKeep = registry; + + queueMicrotask(() => { + let first = {}; + let second = {}; + registry.register(first, "first"); + registry.register(second, "second"); + first = null; + second = null; + }); + queueMicrotask(() => { + Goccia.gc(); + }); + queueMicrotask(() => { + Goccia.gc(); + }); + + return result.then((finalLog) => { + globalThis.__finalizationRegistryKeep = undefined; + expect(registry.unregister({})).toBe(false); + expect(finalLog).toEqual([ + "finalizer:second", + "microtask:second", + "finalizer:first", + "microtask:first" + ]); + }); + }); +}); diff --git a/tests/built-ins/FinalizationRegistry/prototype/register.js b/tests/built-ins/FinalizationRegistry/prototype/register.js new file mode 100644 index 000000000..f71f64afc --- /dev/null +++ b/tests/built-ins/FinalizationRegistry/prototype/register.js @@ -0,0 +1,68 @@ +/*--- +description: FinalizationRegistry.prototype.register validates weak targets and unregister tokens +features: [FinalizationRegistry, Symbol] +---*/ + +test("register returns undefined for object targets", () => { + const registry = new FinalizationRegistry(() => {}); + const target = {}; + expect(registry.register(target, "held")).toBe(undefined); +}); + +test("register accepts non-registered symbol targets and tokens", () => { + const registry = new FinalizationRegistry(() => {}); + const target = Symbol("target"); + const token = Symbol("token"); + expect(registry.register(target, "held", token)).toBe(undefined); + expect(registry.unregister(token)).toBe(true); +}); + +test("register accepts object unregister tokens", () => { + const registry = new FinalizationRegistry(() => {}); + const target = {}; + const token = {}; + registry.register(target, "held", token); + expect(registry.unregister(token)).toBe(true); +}); + +test("register rejects primitive targets and registered symbol targets", () => { + const registry = new FinalizationRegistry(() => {}); + expect(() => registry.register()).toThrow(TypeError); + expect(() => registry.register(undefined, "held")).toThrow(TypeError); + expect(() => registry.register(null, "held")).toThrow(TypeError); + expect(() => registry.register(1, "held")).toThrow(TypeError); + expect(() => registry.register("target", "held")).toThrow(TypeError); + expect(() => registry.register(true, "held")).toThrow(TypeError); + expect(() => registry.register(Symbol.for("registered"), "held")).toThrow(TypeError); +}); + +test("register rejects a held value that is SameValue to the target", () => { + const registry = new FinalizationRegistry(() => {}); + const objectTarget = {}; + const symbolTarget = Symbol("target"); + + expect(() => registry.register(objectTarget, objectTarget)).toThrow(TypeError); + expect(() => registry.register(symbolTarget, symbolTarget)).toThrow(TypeError); +}); + +test("register rejects primitive and registered symbol unregister tokens", () => { + const registry = new FinalizationRegistry(() => {}); + const target = {}; + + expect(() => registry.register(target, "held", null)).toThrow(TypeError); + expect(() => registry.register(target, "held", 1)).toThrow(TypeError); + expect(() => registry.register(target, "held", "token")).toThrow(TypeError); + expect(() => registry.register(target, "held", true)).toThrow(TypeError); + expect(() => registry.register(target, "held", Symbol.for("registered"))).toThrow(TypeError); +}); + +test("register treats undefined unregister token as absent", () => { + const registry = new FinalizationRegistry(() => {}); + expect(registry.register({}, "held", undefined)).toBe(undefined); +}); + +test("register throws TypeError when receiver is not a FinalizationRegistry", () => { + const register = FinalizationRegistry.prototype.register; + expect(() => register.call({}, {}, "held")).toThrow(TypeError); + expect(() => register.call(FinalizationRegistry.prototype, {}, "held")).toThrow(TypeError); +}); diff --git a/tests/built-ins/FinalizationRegistry/prototype/toStringTag.js b/tests/built-ins/FinalizationRegistry/prototype/toStringTag.js new file mode 100644 index 000000000..281d07224 --- /dev/null +++ b/tests/built-ins/FinalizationRegistry/prototype/toStringTag.js @@ -0,0 +1,20 @@ +/*--- +description: FinalizationRegistry.prototype has the correct Symbol.toStringTag +features: [FinalizationRegistry, Symbol.toStringTag] +---*/ + +test("FinalizationRegistry toStringTag is FinalizationRegistry", () => { + const registry = new FinalizationRegistry(() => {}); + expect(Object.prototype.toString.call(registry)).toBe("[object FinalizationRegistry]"); +}); + +test("FinalizationRegistry prototype Symbol.toStringTag descriptor", () => { + const desc = Object.getOwnPropertyDescriptor( + FinalizationRegistry.prototype, + Symbol.toStringTag + ); + expect(desc.value).toBe("FinalizationRegistry"); + expect(desc.writable).toBe(false); + expect(desc.enumerable).toBe(false); + expect(desc.configurable).toBe(true); +}); diff --git a/tests/built-ins/FinalizationRegistry/prototype/unregister.js b/tests/built-ins/FinalizationRegistry/prototype/unregister.js new file mode 100644 index 000000000..914f81d7f --- /dev/null +++ b/tests/built-ins/FinalizationRegistry/prototype/unregister.js @@ -0,0 +1,57 @@ +/*--- +description: FinalizationRegistry.prototype.unregister removes matching cells +features: [FinalizationRegistry, Symbol] +---*/ + +test("unregister returns false when no cells match", () => { + const registry = new FinalizationRegistry(() => {}); + expect(registry.unregister({})).toBe(false); +}); + +test("unregister returns true after removing an object-token registration", () => { + const registry = new FinalizationRegistry(() => {}); + const target = {}; + const token = {}; + registry.register(target, "held", token); + + expect(registry.unregister(token)).toBe(true); + expect(registry.unregister(token)).toBe(false); +}); + +test("unregister removes all cells with the same token", () => { + const registry = new FinalizationRegistry(() => {}); + const token = {}; + + registry.register({}, "first", token); + registry.register({}, "second", token); + + expect(registry.unregister(token)).toBe(true); + expect(registry.unregister(token)).toBe(false); +}); + +test("unregister supports non-registered symbol tokens", () => { + const registry = new FinalizationRegistry(() => {}); + const target = {}; + const token = Symbol("token"); + + registry.register(target, "held", token); + expect(registry.unregister(token)).toBe(true); +}); + +test("unregister rejects primitive and registered symbol tokens", () => { + const registry = new FinalizationRegistry(() => {}); + + expect(() => registry.unregister()).toThrow(TypeError); + expect(() => registry.unregister(undefined)).toThrow(TypeError); + expect(() => registry.unregister(null)).toThrow(TypeError); + expect(() => registry.unregister(1)).toThrow(TypeError); + expect(() => registry.unregister("token")).toThrow(TypeError); + expect(() => registry.unregister(true)).toThrow(TypeError); + expect(() => registry.unregister(Symbol.for("registered"))).toThrow(TypeError); +}); + +test("unregister throws TypeError when receiver is not a FinalizationRegistry", () => { + const unregister = FinalizationRegistry.prototype.unregister; + expect(() => unregister.call({}, {})).toThrow(TypeError); + expect(() => unregister.call(FinalizationRegistry.prototype, {})).toThrow(TypeError); +}); diff --git a/tests/built-ins/FinalizationRegistry/subclassing.js b/tests/built-ins/FinalizationRegistry/subclassing.js new file mode 100644 index 000000000..259bcbf6b --- /dev/null +++ b/tests/built-ins/FinalizationRegistry/subclassing.js @@ -0,0 +1,23 @@ +/*--- +description: FinalizationRegistry supports subclass construction +features: [FinalizationRegistry, class] +---*/ + +test("FinalizationRegistry can be subclassed", () => { + class ChildFinalizationRegistry extends FinalizationRegistry {} + const registry = new ChildFinalizationRegistry(() => {}); + expect(registry instanceof ChildFinalizationRegistry).toBe(true); + expect(registry instanceof FinalizationRegistry).toBe(true); +}); + +test("FinalizationRegistry subclass can add instance fields", () => { + class ChildFinalizationRegistry extends FinalizationRegistry { + constructor(cleanup) { + super(cleanup); + this.name = "child"; + } + } + + const registry = new ChildFinalizationRegistry(() => {}); + expect(registry.name).toBe("child"); +}); diff --git a/tests/built-ins/Object/prototype/toString.js b/tests/built-ins/Object/prototype/toString.js index 63e5277b0..7e8ab33c6 100644 --- a/tests/built-ins/Object/prototype/toString.js +++ b/tests/built-ins/Object/prototype/toString.js @@ -83,6 +83,14 @@ describe("Object.prototype.toString", () => { expect(Object.prototype.toString.call(new WeakMap())).toBe("[object WeakMap]"); }); + test("WeakRef returns [object WeakRef]", () => { + expect(Object.prototype.toString.call(new WeakRef({}))).toBe("[object WeakRef]"); + }); + + test("FinalizationRegistry returns [object FinalizationRegistry]", () => { + expect(Object.prototype.toString.call(new FinalizationRegistry(() => {}))).toBe("[object FinalizationRegistry]"); + }); + test("Promise returns [object Promise]", () => { expect(Object.prototype.toString.call(new Promise((r) => r()))).toBe("[object Promise]"); }); @@ -243,5 +251,10 @@ describe("Object.prototype.toString", () => { expect(Object.prototype.toString.call(new WeakMap([[key, 1]]))).toBe("[object WeakMap]"); expect(Object.prototype.toString.call(new WeakSet([key]))).toBe("[object WeakSet]"); }); + + test("WeakRef and FinalizationRegistry with values return their built-in tags", () => { + expect(Object.prototype.toString.call(new WeakRef({}))).toBe("[object WeakRef]"); + expect(Object.prototype.toString.call(new FinalizationRegistry(() => {}))).toBe("[object FinalizationRegistry]"); + }); }); }); diff --git a/tests/built-ins/WeakRef/constructor.js b/tests/built-ins/WeakRef/constructor.js new file mode 100644 index 000000000..3bd660d97 --- /dev/null +++ b/tests/built-ins/WeakRef/constructor.js @@ -0,0 +1,53 @@ +/*--- +description: WeakRef constructor validates targets and exposes the expected shape +features: [WeakRef, Symbol] +---*/ + +test("WeakRef constructor requires new", () => { + expect(() => WeakRef({})).toThrow(TypeError); +}); + +test("WeakRef constructor accepts objects and non-registered symbols", () => { + const target = {}; + const symbol = Symbol("weak"); + + expect(new WeakRef(target).deref()).toBe(target); + expect(new WeakRef(symbol).deref()).toBe(symbol); +}); + +test("WeakRef constructor rejects primitives and registered symbols", () => { + expect(() => new WeakRef()).toThrow(TypeError); + expect(() => new WeakRef(null)).toThrow(TypeError); + expect(() => new WeakRef(undefined)).toThrow(TypeError); + expect(() => new WeakRef(1)).toThrow(TypeError); + expect(() => new WeakRef("value")).toThrow(TypeError); + expect(() => new WeakRef(true)).toThrow(TypeError); + expect(() => new WeakRef(Symbol.for("registered"))).toThrow(TypeError); +}); + +test("WeakRef.length is 1 with spec descriptor", () => { + expect(WeakRef.length).toBe(1); + const descriptor = Object.getOwnPropertyDescriptor(WeakRef, "length"); + expect(descriptor.value).toBe(1); + expect(descriptor.writable).toBe(false); + expect(descriptor.enumerable).toBe(false); + expect(descriptor.configurable).toBe(true); +}); + +test("WeakRef.prototype.constructor is WeakRef", () => { + expect(WeakRef.prototype.constructor).toBe(WeakRef); + expect(new WeakRef({}).constructor).toBe(WeakRef); +}); + +test("WeakRef.prototype methods are not constructors", () => { + const isConstructor = (fn) => { + try { + new fn(); + return true; + } catch (_) { + return false; + } + }; + + expect(isConstructor(WeakRef.prototype.deref)).toBe(false); +}); diff --git a/tests/built-ins/WeakRef/gc.js b/tests/built-ins/WeakRef/gc.js new file mode 100644 index 000000000..567b63155 --- /dev/null +++ b/tests/built-ins/WeakRef/gc.js @@ -0,0 +1,50 @@ +/*--- +description: WeakRef integrates with Goccia.gc and kept objects +features: [WeakRef, Goccia] +---*/ + +const hasGoccia = typeof Goccia !== "undefined"; + +describe.runIf(hasGoccia)("WeakRef GC behavior", () => { + test("deref keeps the target alive for the current job", () => { + let target = { marker: "kept" }; + const ref = new WeakRef(target); + target = null; + + const first = ref.deref(); + Goccia.gc(); + expect(ref.deref()).toBe(first); + expect(first.marker).toBe("kept"); + }); + + test("target clears after it is unreachable across a job checkpoint", () => { + let target = { marker: "collected" }; + const ref = new WeakRef(target); + target = null; + + const result = new Promise((resolve) => { + queueMicrotask(() => {}); + queueMicrotask(() => { + Goccia.gc(); + }); + queueMicrotask(() => { + Goccia.gc(); + resolve(ref.deref()); + }); + }); + + return result.then((value) => { + expect(value).toBe(undefined); + }); + }); + + test("live target survives explicit GC", () => { + const target = { marker: "live" }; + const ref = new WeakRef(target); + return Promise.resolve().then(() => { + Goccia.gc(); + expect(ref.deref()).toBe(target); + expect(ref.deref().marker).toBe("live"); + }); + }); +}); diff --git a/tests/built-ins/WeakRef/prototype/deref.js b/tests/built-ins/WeakRef/prototype/deref.js new file mode 100644 index 000000000..9ed072d00 --- /dev/null +++ b/tests/built-ins/WeakRef/prototype/deref.js @@ -0,0 +1,23 @@ +/*--- +description: WeakRef.prototype.deref returns a live target or undefined +features: [WeakRef, Symbol] +---*/ + +test("deref returns the target while it is live", () => { + const target = { marker: 1 }; + const ref = new WeakRef(target); + expect(ref.deref()).toBe(target); + expect(ref.deref().marker).toBe(1); +}); + +test("deref works for non-registered symbols", () => { + const target = Symbol("weak"); + const ref = new WeakRef(target); + expect(ref.deref()).toBe(target); +}); + +test("deref throws TypeError when receiver is not a WeakRef", () => { + const deref = WeakRef.prototype.deref; + expect(() => deref.call({})).toThrow(TypeError); + expect(() => deref.call(WeakRef.prototype)).toThrow(TypeError); +}); diff --git a/tests/built-ins/WeakRef/prototype/toStringTag.js b/tests/built-ins/WeakRef/prototype/toStringTag.js new file mode 100644 index 000000000..19f3a64cd --- /dev/null +++ b/tests/built-ins/WeakRef/prototype/toStringTag.js @@ -0,0 +1,16 @@ +/*--- +description: WeakRef.prototype has the correct Symbol.toStringTag +features: [WeakRef, Symbol.toStringTag] +---*/ + +test("WeakRef toStringTag is WeakRef", () => { + expect(Object.prototype.toString.call(new WeakRef({}))).toBe("[object WeakRef]"); +}); + +test("WeakRef prototype Symbol.toStringTag descriptor", () => { + const desc = Object.getOwnPropertyDescriptor(WeakRef.prototype, Symbol.toStringTag); + expect(desc.value).toBe("WeakRef"); + expect(desc.writable).toBe(false); + expect(desc.enumerable).toBe(false); + expect(desc.configurable).toBe(true); +}); diff --git a/tests/built-ins/WeakRef/subclassing.js b/tests/built-ins/WeakRef/subclassing.js new file mode 100644 index 000000000..66fc1f4cf --- /dev/null +++ b/tests/built-ins/WeakRef/subclassing.js @@ -0,0 +1,26 @@ +/*--- +description: WeakRef supports subclass construction +features: [WeakRef, class] +---*/ + +test("WeakRef can be subclassed", () => { + class ChildWeakRef extends WeakRef {} + const target = {}; + const ref = new ChildWeakRef(target); + expect(ref instanceof ChildWeakRef).toBe(true); + expect(ref instanceof WeakRef).toBe(true); + expect(ref.deref()).toBe(target); +}); + +test("WeakRef subclass can add instance fields", () => { + class ChildWeakRef extends WeakRef { + constructor(target) { + super(target); + this.name = "child"; + } + } + const target = {}; + const ref = new ChildWeakRef(target); + expect(ref.name).toBe("child"); + expect(ref.deref()).toBe(target); +}); diff --git a/tests/built-ins/global-properties/global-this.js b/tests/built-ins/global-properties/global-this.js index 7f6640a02..4662fecec 100644 --- a/tests/built-ins/global-properties/global-this.js +++ b/tests/built-ins/global-properties/global-this.js @@ -68,6 +68,22 @@ describe("globalThis exposes built-in constructors", () => { expect(typeof globalThis.Map).toBe("function"); }); + test("WeakSet", () => { + expect(typeof globalThis.WeakSet).toBe("function"); + }); + + test("WeakMap", () => { + expect(typeof globalThis.WeakMap).toBe("function"); + }); + + test("WeakRef", () => { + expect(typeof globalThis.WeakRef).toBe("function"); + }); + + test("FinalizationRegistry", () => { + expect(typeof globalThis.FinalizationRegistry).toBe("function"); + }); + test("Promise", () => { expect(typeof globalThis.Promise).toBe("function"); }); diff --git a/tests/built-ins/structuredClone/collections.js b/tests/built-ins/structuredClone/collections.js index eead38186..5b97a41d5 100644 --- a/tests/built-ins/structuredClone/collections.js +++ b/tests/built-ins/structuredClone/collections.js @@ -89,4 +89,28 @@ describe("Weak collection cloning", () => { } expect(true).toBe(false); }); + + test("WeakRef is not cloneable", () => { + try { + structuredClone(new WeakRef({})); + } catch (e) { + expect(e instanceof DOMException).toBe(true); + expect(e.name).toBe("DataCloneError"); + expect(e.code).toBe(25); + return; + } + expect(true).toBe(false); + }); + + test("FinalizationRegistry is not cloneable", () => { + try { + structuredClone(new FinalizationRegistry(() => {})); + } catch (e) { + expect(e instanceof DOMException).toBe(true); + expect(e.name).toBe("DataCloneError"); + expect(e.code).toBe(25); + return; + } + expect(true).toBe(false); + }); }); diff --git a/tests/language/classes/class-length-property.js b/tests/language/classes/class-length-property.js index 7f108747d..9d18f31b5 100644 --- a/tests/language/classes/class-length-property.js +++ b/tests/language/classes/class-length-property.js @@ -76,6 +76,8 @@ describe("class length property", () => { expect(Boolean.length).toBe(1); expect(ArrayBuffer.length).toBe(1); expect(SharedArrayBuffer.length).toBe(1); + expect(WeakRef.length).toBe(1); + expect(FinalizationRegistry.length).toBe(1); // WHATWG: URL(url, base?) — required url means length 1. expect(URL.length).toBe(1); // Fetch: Response constructor reports 1 in WPT/V8. From 946cbe1ea59b8f10fc698eebafd1d58e118a7de0 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Wed, 3 Jun 2026 11:12:06 +0200 Subject: [PATCH 2/3] fix(gc): clear dead finalization unregister tokens Avoid retaining stale weak unregister-token pointers in FinalizationRegistry cells after GC. Keep the target cell alive for later cleanup and add regression coverage for unregister after token collection. --- ...occia.Values.FinalizationRegistryValue.pas | 7 ++++ tests/built-ins/FinalizationRegistry/gc.js | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/source/units/Goccia.Values.FinalizationRegistryValue.pas b/source/units/Goccia.Values.FinalizationRegistryValue.pas index ae5f22b15..4fb60a410 100644 --- a/source/units/Goccia.Values.FinalizationRegistryValue.pas +++ b/source/units/Goccia.Values.FinalizationRegistryValue.pas @@ -192,6 +192,13 @@ procedure TGocciaFinalizationRegistryValue.SweepWeakReferences; begin EnqueueCleanup(Cell.HeldValue); FCells.Delete(I); + end + else if Cell.HasUnregisterToken and Assigned(Cell.UnregisterToken) and + not Cell.UnregisterToken.GCMarked then + begin + Cell.UnregisterToken := nil; + Cell.HasUnregisterToken := False; + FCells[I] := Cell; end; end; end; diff --git a/tests/built-ins/FinalizationRegistry/gc.js b/tests/built-ins/FinalizationRegistry/gc.js index 997ba8a20..3ad97d34d 100644 --- a/tests/built-ins/FinalizationRegistry/gc.js +++ b/tests/built-ins/FinalizationRegistry/gc.js @@ -137,6 +137,40 @@ describe.runIf(hasGoccia)("FinalizationRegistry GC behavior", () => { }); }); + test("dead unregister tokens are cleared without dropping the target cell", () => { + let done; + const registry = new FinalizationRegistry((held) => { + done(held); + }); + globalThis.__finalizationRegistryKeep = registry; + globalThis.__finalizationRegistryTarget = {}; + + const result = new Promise((resolve) => { + done = resolve; + }); + + queueMicrotask(() => { + const token = {}; + registry.register(globalThis.__finalizationRegistryTarget, "held", token); + }); + queueMicrotask(() => { + Goccia.gc(); + expect(registry.unregister({})).toBe(false); + globalThis.__finalizationRegistryTarget = undefined; + }); + queueMicrotask(() => { + Goccia.gc(); + }); + queueMicrotask(() => { + Goccia.gc(); + }); + + return result.then((held) => { + globalThis.__finalizationRegistryKeep = undefined; + expect(held).toBe("held"); + }); + }); + test("cleanup jobs enqueue microtasks before the next cleanup job runs", () => { const log = []; let cleanupCount = 0; From 05cdd29de5249007a85aa76f1fbcdfb3c66ccf40 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Wed, 3 Jun 2026 12:02:45 +0200 Subject: [PATCH 3/3] fix(gc): avoid duplicate microtask temp roots --- source/units/Goccia.MicrotaskQueue.pas | 82 ++++++++++---------------- 1 file changed, 30 insertions(+), 52 deletions(-) diff --git a/source/units/Goccia.MicrotaskQueue.pas b/source/units/Goccia.MicrotaskQueue.pas index 24327aae2..5eec363f3 100644 --- a/source/units/Goccia.MicrotaskQueue.pas +++ b/source/units/Goccia.MicrotaskQueue.pas @@ -147,64 +147,42 @@ procedure TGocciaMicrotaskQueue.ExecuteTask( begin Promise := TGocciaPromiseValue(ATask.ResultPromise); - if Assigned(TGarbageCollector.Instance) then + if Assigned(ATask.Handler) and ATask.Handler.IsCallable then begin - if Assigned(ATask.Handler) then - TGarbageCollector.Instance.AddTempRoot(ATask.Handler); - if Assigned(ATask.Value) then - TGarbageCollector.Instance.AddTempRoot(ATask.Value); - if Assigned(Promise) then - TGarbageCollector.Instance.AddTempRoot(Promise); - end; - - try - if Assigned(ATask.Handler) and ATask.Handler.IsCallable then - begin - CallArgs := TGocciaArgumentsCollection.Create([ATask.Value]); + CallArgs := TGocciaArgumentsCollection.Create([ATask.Value]); + try try - try - HandlerResult := TGocciaFunctionBase(ATask.Handler).Call( - CallArgs, TGocciaUndefinedLiteralValue.UndefinedValue); + HandlerResult := TGocciaFunctionBase(ATask.Handler).Call( + CallArgs, TGocciaUndefinedLiteralValue.UndefinedValue); + if Assigned(Promise) then + Promise.Resolve(HandlerResult); + except + on E: EGocciaBytecodeThrow do if Assigned(Promise) then - Promise.Resolve(HandlerResult); - except - on E: EGocciaBytecodeThrow do - if Assigned(Promise) then - Promise.Reject(E.ThrownValue) - else - raise; - on E: TGocciaThrowValue do - if Assigned(Promise) then - Promise.Reject(E.Value) - else - raise; - end; - finally - CallArgs.Free; - end; - end - else - begin - if Assigned(Promise) then - begin - case ATask.ReactionType of - prtFulfill: Promise.Resolve(ATask.Value); - prtReject: Promise.Reject(ATask.Value); - prtThenableResolve: - if ATask.Value is TGocciaPromiseValue then - Promise.SubscribeTo(TGocciaPromiseValue(ATask.Value)); - end; + Promise.Reject(E.ThrownValue) + else + raise; + on E: TGocciaThrowValue do + if Assigned(Promise) then + Promise.Reject(E.Value) + else + raise; end; + finally + CallArgs.Free; end; - finally - if Assigned(TGarbageCollector.Instance) then + end + else + begin + if Assigned(Promise) then begin - if Assigned(ATask.Handler) then - TGarbageCollector.Instance.RemoveTempRoot(ATask.Handler); - if Assigned(ATask.Value) then - TGarbageCollector.Instance.RemoveTempRoot(ATask.Value); - if Assigned(Promise) then - TGarbageCollector.Instance.RemoveTempRoot(Promise); + case ATask.ReactionType of + prtFulfill: Promise.Resolve(ATask.Value); + prtReject: Promise.Reject(ATask.Value); + prtThenableResolve: + if ATask.Value is TGocciaPromiseValue then + Promise.SubscribeTo(TGocciaPromiseValue(ATask.Value)); + end; end; end; end;