Skip to content

fix!: stop per-object memory growth from source proxies, report proxy memory sizes - #377

Merged
robertherber merged 3 commits into
masterfrom
fix/proxy-memory-growth
Sep 7, 2026
Merged

robertherber merged 3 commits into
masterfrom
fix/proxy-memory-growth

Conversation

@robertherber

@robertherber robertherber commented Sep 7, 2026

Copy link
Copy Markdown
Member

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 SourceProxy HybridObject just to carry two strings.

Nitro's JSICache appends 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)

source values embedded in returned data are now plain { name, bundleIdentifier } objects instead of SourceProxy HybridObjects:

  • sample.sourceRevision.source on every sample type
  • QueryStatisticsResponse.sources and QueryStatisticsResponseFromSingleSource.source

.name / .bundleIdentifier keep working. toJSON(), equals(), dispose() and passing these values into filter.sources no longer work; the changeset shows how to look a SourceProxy up via querySources() for filtering. querySources(), currentAppSource() and filter.sources keep using SourceProxy.

Memory improvements

  • No per-sample / per-bucket HybridObjects (the fix above). This is what flattens the growth curve.
  • memorySize on WorkoutProxy and SourceProxy, computed once in init on the background executor (estimates in ios/MemoryEstimation.swift), so Hermes accounts for the HKWorkout / HKSource graphs each proxy keeps alive and collects stale proxies sooner.
  • Route location fetch no longer uses try!; errors propagate to the JS promise, and the HKWorkoutRouteQuery callback resumes the continuation exactly once through a single finish closure.
  • README section on memory considerations, dispose(), and how to filter by source now.

Benchmark harness

bun run benchmark:memory <scenario> [iterations] in apps/example (seed once with memory-seed, then memory-fetch, memory-fetch-dispose, memory-routes, memory-routes-dispose; output dir via BENCHMARK_OUT_DIR). It reuses the contract runner, samples the launched process' physical footprint via footprint, and prints a per-phase table from a typed summarizer script. Kept out of the CI contract run. The contract runner gained CONTRACT_COMMAND, CONTRACT_REPORT_COPY, REPORT_TIMEOUT_SECONDS (validated) and MEMORY_SAMPLE_LOG overrides, 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.

Build 40 fetch iterations 120 fetch iterations 40 route iterations
master (14.1.0) +60 MB +147 MB +44 MB
+ memorySize on proxies only +51 MB +141 MB +3 MB
this PR (plain sources + memorySize) +16 MB +18 MB +5 MB

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 tests
  • xcodebuild of the example app for the iPhone 17 Pro simulator
  • bun run test:contracts (quantity / category / workout round-trips) against the final build
  • benchmark runs above on the final build, plus a review round (12 findings addressed in ea936f3)

Issues

🤖 Generated with Claude Code

…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>
Copilot AI lite review requested due to automatic review settings September 7, 2026 16:28
@changeset-bot

changeset-bot Bot commented Sep 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ea936f3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@kingstinct/react-native-healthkit Major

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

@pkg-pr-new

pkg-pr-new Bot commented Sep 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/kingstinct/react-native-healthkit/@kingstinct/react-native-healthkit@377

commit: ea936f3

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 SourceProxy HybridObjects by making sourceRevision.source a 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.

Comment thread apps/example/scripts/run-healthkit-contracts.sh Outdated
Comment thread README.md Outdated
…med native memory

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@robertherber

Copy link
Copy Markdown
Member Author

Addressed both Copilot comments in 4ee8b27: sample_memory now creates the log's parent directory before truncating, and the README no longer claims memory returns fully to baseline (it mentions Nitro's per-object bookkeeping that lives for the runtime's lifetime).

…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>
@robertherber robertherber changed the title fix: stop per-sample HybridObject memory growth, report proxy memory sizes fix!: stop per-object memory growth from source proxies, report proxy memory sizes Sep 7, 2026
@robertherber
robertherber merged commit d279bc7 into master Sep 7, 2026
9 checks passed
@robertherber
robertherber deleted the fix/proxy-memory-growth branch September 7, 2026 17:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants