Skip to content

test: Add GC lifecycle test for identity-cached wrappers#6

Closed
krodak wants to merge 16 commits intomainfrom
feat/identity-gc-lifecycle-test
Closed

test: Add GC lifecycle test for identity-cached wrappers#6
krodak wants to merge 16 commits intomainfrom
feat/identity-gc-lifecycle-test

Conversation

@krodak
Copy link
Copy Markdown
Collaborator

@krodak krodak commented Apr 27, 2026

Overview

Add a test proving that the WeakRef-based identity cache does not prevent garbage collection of Swift heap objects.

The existing GC lifecycle test (testJSWrapperIsDeallocatedAfterFinalization) only covers non-identity classes. This adds an equivalent for @JS(identityMode: true) classes where the identity cache holds a WeakRef to the wrapper.

What it tests

Creates a RetainLeakSubject (identity-mode class), crosses it to JS 5 times (filling the identity cache with a WeakRef entry), drops all Swift-side references, triggers GC + event loop ticks, and verifies:

  • The Swift object is deallocated (weakSubject == nil)
  • deinit fires exactly once

This covers the scenario where only TypeScript holds a reference to an identity-cached Swift object, then that reference becomes unreachable.

What changed

  • IdentityModeTests.swift — Added testIdentityCachedWrapperIsReclaimedByGC async test. Added @JSFunction gc() import (same pattern as SwiftClassSupportTests).
  • Generated/BridgeJS.swift + Generated/JavaScript/BridgeJS.json — Regenerated to include gc import binding.

krodak added 16 commits April 17, 2026 11:17
…ntity caching

Add identityMode: "pointer" option to BridgeJS instantiation. When enabled,
a WeakRef-based identity cache keyed by pointer ensures the same Swift heap
pointer returns the same JS wrapper (=== equality).

Each class gets its own FinalizationRegistry and identity cache stored on the
deinit function. Off by default, zero overhead when not enabled.
Replace per-class FinalizationRegistry instances with a single shared
registry at module level. Move identity cache from deinit function property
to per-class static __identityCache field. Cleaner codegen, fewer
allocations, easier to inspect in DevTools.
…ence

Each boundary crossing calls passRetained on the Swift side. On cache hit,
the wrapper is returned without creating a new FinalizationRegistry entry,
leaving the retain unbalanced. Call deinit(pointer) on cache hit to
immediately release the extra retain.

Also fix deinit reference for namespaced classes to use abiName instead
of short class name.
Add Tests/BridgeJSRuntimeTests/IdentityModeSupportTests module covering:
- Wrapper identity for shared Swift objects
- Cache invalidation on release
- Different classes don't collide on same pointer
- Retain leak regression test for cache hits
- Array identity preservation

Wire IDENTITY_MODE env var through prelude.mjs to toggle instantiateOptions.
Add unittest-pointer Makefile target for running tests with identityMode:
"pointer".
Restore the no-op polyfill pattern for environments without FinalizationRegistry
instead of null, matching the upstream convention. Remove finalizer parameter
from makeFresh since the polyfill is always callable. Use has() guard before
stale WeakRef cleanup. Remove formatting-only changes from instantiate.d.ts.
…ToGlobal pattern

Add identityMode field to BridgeJSConfig, flow through SwiftToSkeleton and
ExportedSkeleton to BridgeJSLink. Generated JS uses config value as default
with runtime option as override via nullish coalescing.

Create dedicated BridgeJSIdentityTests target with identityMode: pointer in
its bridge-js.config.json. Remove IDENTITY_MODE env var, instantiateOptions
spread from prelude.mjs, and unittest-pointer Makefile target. Identity tests
now run as part of the normal test suite.
…x.js

TypeScript strict excess property check rejects identityMode in the spread
into DefaultNodeSetupOptions. Destructure it out before spreading, since
it's already handled separately via the instantiateOptions pass-through.
…generate script

The Generated files were incorrectly copied from BridgeJSRuntimeTests,
containing types from the wrong module. Regenerate with BridgeJSTool for
the BridgeJSIdentityTests target. Add target to bridge-js-generate.sh.
Fix SwiftToSkeleton formatting.
When multiple targets share one createInstantiator (e.g. test package),
use compactMap to find the first non-nil identityMode across all skeletons
instead of reading from the first skeleton which may not have it set.
Extend run.js with --identity-mode, --identity-iterations, --identity-reuse-pools,
and --identity-memory CLI flags. Extract identity scenarios into
lib/identity-benchmarks.js: roundtrip reuse, bulk pool return (100 cached
objects), churn (create-roundtrip-release), consume, and create paths.
Memory telemetry via --identity-memory.

Update README.md with identity mode flags and scenario descriptions.
Add IdentityCacheBenchmark with setupPool/getPoolRepeated for bulk array
return scenarios. Update generated BridgeJS bindings for benchmark target.
Benchmark results can be noisy due to GC timing and V8 JIT compilation.
IQR filtering discards values outside Q1-1.5*IQR to Q3+1.5*IQR before
computing statistics. The Samples column shows retained count (e.g.
'4 (-1)' means 4 kept, 1 discarded). Falls back to the full dataset
if fewer than 4 samples.

Applies to all benchmarks, not just identity mode.
feat: Add opt-in identityMode pointer for SwiftHeapObject wrapper identity caching
Add identityMode: Bool parameter to @js macro. When set to true on a class,
that class uses pointer identity caching. When false or not set, the class
uses the bridge-js.config.json default.

Identity is resolved entirely at codegen time - no runtime option. Classes
with identity mode get static __identityCache passed to __wrap. Classes
without it pass null. No runtime branching in __construct.

Resolution: @js(identityMode: true/false) > bridge-js.config.json > default (off).
feat: Add per-class identityMode via @js macro parameter
Verify that WeakRef-based identity cache does not prevent garbage collection.
Creates an identity-mode object, crosses it 5 times (filling identity cache),
drops all references, triggers GC + event loop ticks, and asserts the Swift
object is deallocated and deinit fires exactly once.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant