fix!: stop per-object memory growth from source proxies, report proxy memory sizes - #377
Conversation
…ry sizes - sourceRevision.source is now a plain Source struct instead of a SourceProxy HybridObject (one was created per serialized sample, and Nitro keeps a JSICache slot per HybridObject for the runtime's lifetime) - report estimated memorySize for WorkoutProxy and SourceProxy so Hermes accounts for the native memory they keep alive - propagate errors from workout route location queries instead of try! - document dispose() and memory considerations in the README - add a simulator memory benchmark (bun run benchmark:memory) to the example app's contract harness Closes #274, refs #370 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: ea936f3 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
commit: |
There was a problem hiding this comment.
🟡 Changes recommended
The contract runner’s new memory sampling path can fail for custom log locations because it doesn’t ensure the output directory exists (and the README currently overstates when all native memory is reclaimed).
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR addresses unbounded native-memory growth scenarios by reducing per-sample HybridObject allocations, improving native memory accounting for long-lived proxy objects, and adding tooling/docs to reproduce and measure memory behavior in the example app.
Changes:
- Stop creating per-sample
SourceProxyHybridObjects by makingsourceRevision.sourcea plain{ name, bundleIdentifier }struct. - Report estimated native memory sizes for
WorkoutProxy/SourceProxy, and harden route location fetching by propagating errors and ensuring continuations resume at most once. - Add documentation and a simulator memory benchmark harness (scripts + contract scenario) to quantify footprint changes.
File summaries
| File | Description |
|---|---|
| README.md | Documents proxy lifecycle, dispose(), and memory tips for large datasets. |
| packages/react-native-healthkit/src/types/Source.ts | Updates SourceRevision.source type to the plain Source struct. |
| packages/react-native-healthkit/src/specs/SourceProxy.nitro.ts | Exports the Source interface so it can be referenced externally. |
| packages/react-native-healthkit/ios/WorkoutProxy.swift | Makes route location fetching throwable and adds memorySize reporting for workouts. |
| packages/react-native-healthkit/ios/SourceProxy.swift | Adds memorySize reporting for sources. |
| packages/react-native-healthkit/ios/Serializers.swift | Serializes HKSourceRevision.source into the plain Source struct instead of a proxy. |
| packages/react-native-healthkit/ios/MemoryEstimation.swift | Introduces shared heuristics for estimating native object graph sizes. |
| apps/example/scripts/run-healthkit-memory-benchmark.sh | Adds a benchmark runner that correlates contract phases with sampled footprint. |
| apps/example/scripts/run-healthkit-contracts.sh | Adds env overrides for scenario/timeout and optional memory sampling while awaiting reports. |
| apps/example/package.json | Adds benchmark:memory script entrypoint. |
| apps/example/contracts/memoryBenchmark.ts | Adds memory benchmark scenarios mirroring reported leak patterns (#274/#370). |
| apps/example/app/contracts.tsx | Allows running benchmarks and parsing scenario:iterations suffix. |
| .changeset/source-revision-plain-struct.md | Minor release note for the sourceRevision.source type change. |
| .changeset/report-proxy-memory-size.md | Patch release note for memorySize reporting + route error propagation + docs. |
Review details
- Files reviewed: 14/14 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…med native memory Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Addressed both Copilot comments in 4ee8b27: |
…ess fixes - QueryStatisticsResponse.sources / FromSingleSource.source are plain Source structs too (no more per-bucket SourceProxy HybridObjects) - compute WorkoutProxy/SourceProxy memorySize once in init instead of a lazy var evaluated on the JS thread during conversion; Nitro overhead constant applied at the proxy layer only - route location fetch: single finish closure guards the continuation - benchmark harness: iterations travel in the launch command, unknown scenario ids report immediately, failed scenarios still get a summary, sampler tracks the launched pid, seed tops up missing heart-rate samples, typed summarizer script, contract runner validates the timeout and copies the report before the pass/fail check - single major changeset with breaking changes and memory improvements Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Summary
Investigation of #274 and #370. Both come down to native memory the Hermes GC cannot see, but the measurable, unbounded growth turned out to be a Nitro-level issue amplified by one design choice in this library: every serialized sample, and every statistics bucket, created a
SourceProxyHybridObject just to carry two strings.Nitro's
JSICacheappends a weak-reference slot for every HybridObject (and for every JS function passed to native, e.g. Promise resolvers) and only frees them when the runtime dies. That is roughly 120 bytes per object that never comes back; it is tracked upstream in margelo/nitro#1469 and margelo/nitro#1533. With 43k heart-rate samples per fetch, as in the #274 repro, that is several MB per fetch, forever.Breaking changes (major)
sourcevalues embedded in returned data are now plain{ name, bundleIdentifier }objects instead ofSourceProxyHybridObjects:sample.sourceRevision.sourceon every sample typeQueryStatisticsResponse.sourcesandQueryStatisticsResponseFromSingleSource.source.name/.bundleIdentifierkeep working.toJSON(),equals(),dispose()and passing these values intofilter.sourcesno longer work; the changeset shows how to look aSourceProxyup viaquerySources()for filtering.querySources(),currentAppSource()andfilter.sourceskeep usingSourceProxy.Memory improvements
memorySizeonWorkoutProxyandSourceProxy, computed once ininiton the background executor (estimates inios/MemoryEstimation.swift), so Hermes accounts for theHKWorkout/HKSourcegraphs each proxy keeps alive and collects stale proxies sooner.try!; errors propagate to the JS promise, and theHKWorkoutRouteQuerycallback resumes the continuation exactly once through a single finish closure.dispose(), and how to filter by source now.Benchmark harness
bun run benchmark:memory <scenario> [iterations]in apps/example (seed once withmemory-seed, thenmemory-fetch,memory-fetch-dispose,memory-routes,memory-routes-dispose; output dir viaBENCHMARK_OUT_DIR). It reuses the contract runner, samples the launched process' physical footprint viafootprint, and prints a per-phase table from a typed summarizer script. Kept out of the CI contract run. The contract runner gainedCONTRACT_COMMAND,CONTRACT_REPORT_COPY,REPORT_TIMEOUT_SECONDS(validated) andMEMORY_SAMPLE_LOGoverrides, waits for the report to be valid JSON, and unknown scenario ids now fail immediately instead of waiting for the timeout.Measurements
Simulator, Debug build, iPhone 17 Pro, 200 seeded workouts with 10,000 heart-rate samples and 20 routes. One fetch iteration mirrors the #274 repro (all workouts, then heart-rate samples per workout). Values are physical-footprint growth from idle to after a 20 s settle; nothing was reclaimed during settle in any run.
Calling
dispose()on workout proxies made no measurable difference in any build, which is what pointed at Nitro's per-object cache rather than at HealthKit objects. The remaining growth is one slot per workout proxy plus two per Promise, and goes away once the upstream pruning lands.Verification
bun typecheck,bun lint,bun run swiftlint(only pre-existing warnings), package unit testsxcodebuildof the example app for the iPhone 17 Pro simulatorbun run test:contracts(quantity / category / workout round-trips) against the final buildIssues
SourceProxywas the growth; reproduced and measured above.getWorkoutRoutes()growth until jetsam): the route path itself did not leak in the benchmark, but every call left a workout proxy plus two Promise cache slots behind, and the samples fetched alongside left one per sample. Those are gone or reduced here; thetry!in the route location fetch, which turned a HealthKit error into a hard crash, is fixed as well. Would be good to have the reporter confirm with the pkg.pr.new build from this PR.getWorkoutRouteswhen a workout has a route): a failingHKWorkoutRouteQuerynow rejects the promise instead of trapping ontry!, which matches that report.🤖 Generated with Claude Code