@onekeyfe/react-native-perf-stats 3.0.35 → 3.0.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/android/src/main/java/com/margelo/nitro/reactnativeperfstats/PerfStatsInitProvider.kt +11 -4
  2. package/android/src/main/java/com/margelo/nitro/reactnativeperfstats/ReactNativePerfStats.kt +333 -24
  3. package/ios/ReactNativePerfStats.swift +360 -15
  4. package/lib/module/index.js +77 -5
  5. package/lib/module/index.js.map +1 -1
  6. package/lib/typescript/src/ReactNativePerfStats.nitro.d.ts +109 -1
  7. package/lib/typescript/src/ReactNativePerfStats.nitro.d.ts.map +1 -1
  8. package/lib/typescript/src/index.d.ts +31 -0
  9. package/lib/typescript/src/index.d.ts.map +1 -1
  10. package/nitrogen/generated/android/c++/JFunc_void_MemoryWarningEvent.hpp +79 -0
  11. package/nitrogen/generated/android/c++/JHybridReactNativePerfStatsSpec.cpp +24 -0
  12. package/nitrogen/generated/android/c++/JHybridReactNativePerfStatsSpec.hpp +3 -0
  13. package/nitrogen/generated/android/c++/JMemoryWarningEvent.hpp +66 -0
  14. package/nitrogen/generated/android/c++/JMemoryWarningLevel.hpp +59 -0
  15. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeperfstats/Func_void_MemoryWarningEvent.kt +80 -0
  16. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeperfstats/HybridReactNativePerfStatsSpec.kt +17 -0
  17. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeperfstats/MemoryWarningEvent.kt +44 -0
  18. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeperfstats/MemoryWarningLevel.kt +21 -0
  19. package/nitrogen/generated/android/reactnativeperfstatsOnLoad.cpp +2 -0
  20. package/nitrogen/generated/ios/ReactNativePerfStats-Swift-Cxx-Bridge.cpp +8 -0
  21. package/nitrogen/generated/ios/ReactNativePerfStats-Swift-Cxx-Bridge.hpp +37 -0
  22. package/nitrogen/generated/ios/ReactNativePerfStats-Swift-Cxx-Umbrella.hpp +7 -0
  23. package/nitrogen/generated/ios/c++/HybridReactNativePerfStatsSpecSwift.hpp +27 -0
  24. package/nitrogen/generated/ios/swift/Func_void_MemoryWarningEvent.swift +47 -0
  25. package/nitrogen/generated/ios/swift/HybridReactNativePerfStatsSpec.swift +3 -0
  26. package/nitrogen/generated/ios/swift/HybridReactNativePerfStatsSpec_cxx.swift +39 -0
  27. package/nitrogen/generated/ios/swift/MemoryWarningEvent.swift +58 -0
  28. package/nitrogen/generated/ios/swift/MemoryWarningLevel.swift +40 -0
  29. package/nitrogen/generated/shared/c++/HybridReactNativePerfStatsSpec.cpp +3 -0
  30. package/nitrogen/generated/shared/c++/HybridReactNativePerfStatsSpec.hpp +7 -0
  31. package/nitrogen/generated/shared/c++/MemoryWarningEvent.hpp +84 -0
  32. package/nitrogen/generated/shared/c++/MemoryWarningLevel.hpp +76 -0
  33. package/package.json +1 -1
  34. package/src/ReactNativePerfStats.nitro.ts +114 -1
  35. package/src/index.tsx +90 -5
@@ -2,9 +2,16 @@ import NitroModules
2
2
  import ReactNativeNativeLogger
3
3
  import Darwin
4
4
  import UIKit
5
+ import WebKit
6
+ // URLCache and Date come transitively from UIKit. malloc_zone_pressure_relief
7
+ // lives in <malloc/malloc.h>, which is reached via `import Darwin`.
8
+ import Foundation
5
9
 
6
10
  private let kTag = "PerfStats"
7
11
  private let kMinIntervalMs: Double = 200
12
+ // 24 h. The interval feeds DispatchSource.schedule(deadline: .now() + .milliseconds(Int(ms))),
13
+ // and Int(Double) traps on values that don't fit in Int — cap before the cast.
14
+ private let kMaxIntervalMs: Double = 86_400_000
8
15
 
9
16
  // Anomaly logging thresholds. We only emit a warn after the metric has
10
17
  // stayed over the threshold for kAnomalySustainSamples in a row, to
@@ -26,7 +33,12 @@ private let kJsFpsHintTtlSec: Double = 2.0
26
33
  class ReactNativePerfStats: HybridReactNativePerfStatsSpec {
27
34
 
28
35
  func start(intervalMs: Double) throws {
29
- Sampler.shared.start(intervalMs: max(intervalMs, kMinIntervalMs))
36
+ // Sanitise the JS-supplied value: NaN/Inf would trap on Int(...)
37
+ // inside DispatchSource scheduling, and max(NaN, x) returns NaN
38
+ // in Swift so the lower-bound clamp below can't catch it.
39
+ let finite = intervalMs.isFinite ? intervalMs : 1000.0
40
+ let clamped = min(max(finite, kMinIntervalMs), kMaxIntervalMs)
41
+ Sampler.shared.start(intervalMs: clamped)
30
42
  }
31
43
 
32
44
  func stop() throws {
@@ -43,13 +55,38 @@ class ReactNativePerfStats: HybridReactNativePerfStatsSpec {
43
55
 
44
56
  func sample() throws -> Promise<PerfSample> {
45
57
  return Promise.async {
46
- return Sampler.shared.takeSample()
58
+ // recordBaseline=false: one-shot reads must not write the
59
+ // CPU baseline used by the periodic tick. See takeSample.
60
+ return Sampler.shared.takeSample(recordBaseline: false)
47
61
  }
48
62
  }
49
63
 
50
64
  func setJsFpsHint(fps: Double) throws {
65
+ // Drop NaN/Inf at the boundary; JsFpsHolder caches the value and
66
+ // the overlay would happily render "inf fps" otherwise.
67
+ guard fps.isFinite else { return }
51
68
  JsFpsHolder.shared.set(fps: fps)
52
69
  }
70
+
71
+ func addMemoryWarningListener(callback: @escaping (MemoryWarningEvent) -> Void) throws -> Double {
72
+ return Double(MemoryWarningCenter.shared.add(callback: callback))
73
+ }
74
+
75
+ func removeMemoryWarningListener(id: Double) throws {
76
+ // Int(exactly:) returns nil for NaN/Inf/non-integral/out-of-range
77
+ // values; plain Int(NaN) traps. We treat any of those as "unknown
78
+ // id", consistent with the doc note that removal of unknown ids
79
+ // is a no-op.
80
+ guard let intId = Int(exactly: id) else { return }
81
+ MemoryWarningCenter.shared.remove(id: intId)
82
+ }
83
+
84
+ func cleanupNativeCaches() throws {
85
+ // Caller-initiated: pass rssBefore=nil so the bg reclaim-delta
86
+ // log is skipped (the OS-warning path is the only one that
87
+ // wants that "before vs after" line; on-demand callers don't).
88
+ MemoryWarningCenter.shared.performNativeCleanup()
89
+ }
53
90
  }
54
91
 
55
92
  // MARK: - Sampler
@@ -82,9 +119,9 @@ private final class Sampler {
82
119
  private var lastUiFpsLogSec: Double = 0
83
120
  private var lastJsFpsLogSec: Double = 0
84
121
 
85
- // Last UI FPS computed by the periodic timer. takeSample() reads this
86
- // directly so one-shot sample() calls do not steal frames from the
87
- // UiFpsMonitor counter (which is owned by the periodic timer).
122
+ // Last UI FPS computed by the periodic timer. Guarded by `lock`:
123
+ // the periodic tick writes from the sampler queue while takeSample()
124
+ // may read from Nitro's worker queue when called via Promise.async.
88
125
  private var lastUiFps: Double = 0
89
126
 
90
127
  func start(intervalMs ms: Double) {
@@ -104,6 +141,16 @@ private final class Sampler {
104
141
  }
105
142
  return
106
143
  }
144
+ // Cold-start path: reset CPU baseline now, before scheduling
145
+ // the timer. Doing it here rather than in stop() also stops
146
+ // sample() (Promise.async on a worker queue, not this serial
147
+ // queue) from writing a fresh baseline between stop() and
148
+ // start(). One-shot sample() now passes recordBaseline=false
149
+ // and the periodic tick is the only writer.
150
+ self.lock.lock()
151
+ self.lastCpuSec = -1
152
+ self.lastMonoSec = -1
153
+ self.lock.unlock()
107
154
  self.running = true
108
155
  let t = DispatchSource.makeTimerSource(queue: self.queue)
109
156
  t.schedule(
@@ -114,7 +161,10 @@ private final class Sampler {
114
161
  guard let self = self, self.running else { return }
115
162
  // Refresh cached UI FPS *before* takeSample reads it, so
116
163
  // the value covers exactly the just-elapsed interval.
117
- self.lastUiFps = UiFpsMonitor.shared.readAndReset(nowSec: self.monotonicSec())
164
+ let newUiFps = UiFpsMonitor.shared.readAndReset(nowSec: self.monotonicSec())
165
+ self.lock.lock()
166
+ self.lastUiFps = newUiFps
167
+ self.lock.unlock()
118
168
  let s = self.takeSample()
119
169
  Overlay.shared.update(sample: s)
120
170
  self.checkAnomalyAndLog(sample: s)
@@ -131,19 +181,29 @@ private final class Sampler {
131
181
  self.running = false
132
182
  self.timer?.cancel()
133
183
  self.timer = nil
184
+ self.lock.lock()
134
185
  self.lastUiFps = 0
186
+ self.lock.unlock()
135
187
  }
136
188
  UiFpsMonitor.shared.stop()
137
189
  Overlay.shared.hide()
138
190
  }
139
191
 
140
- func takeSample() -> PerfSample {
192
+ /// - parameter recordBaseline: `true` for the periodic timer (the
193
+ /// next sample's CPU delta is computed against this read); `false`
194
+ /// for one-shot `sample()` calls so they don't pollute the periodic
195
+ /// CPU% baseline. A one-shot call between two periodic ticks (or
196
+ /// between stop() and start()) would otherwise insert a new
197
+ /// baseline that the next periodic tick reads, producing a CPU%
198
+ /// that spans the gap.
199
+ func takeSample(recordBaseline: Bool = true) -> PerfSample {
141
200
  let nowMono = monotonicSec()
142
201
  let nowCpu = processCpuSec()
143
202
  let rssBytes = residentBytes()
144
203
  let nowWallMs = Date().timeIntervalSince1970 * 1000.0
145
204
 
146
205
  var cpuPct: Double = 0
206
+ var uiFps: Double = 0
147
207
  lock.lock()
148
208
  if nowCpu >= 0 && lastCpuSec >= 0 && lastMonoSec > 0 {
149
209
  let dCpu = nowCpu - lastCpuSec
@@ -152,16 +212,17 @@ private final class Sampler {
152
212
  cpuPct = (dCpu / dWall) * 100.0
153
213
  }
154
214
  }
155
- if nowCpu >= 0 {
215
+ if recordBaseline && nowCpu >= 0 {
156
216
  lastCpuSec = nowCpu
157
217
  lastMonoSec = nowMono
158
218
  }
219
+ uiFps = lastUiFps
159
220
  lock.unlock()
160
221
 
161
222
  return PerfSample(
162
223
  cpu: cpuPct,
163
224
  rss: Double(rssBytes),
164
- uiFps: lastUiFps,
225
+ uiFps: uiFps,
165
226
  jsFps: JsFpsHolder.shared.read(nowSec: nowMono),
166
227
  timestamp: nowWallMs
167
228
  )
@@ -270,6 +331,24 @@ private final class Sampler {
270
331
  return Double(ts.tv_sec) + Double(ts.tv_nsec) / 1_000_000_000.0
271
332
  }
272
333
 
334
+ /// Public alias used by `MemoryWarningCenter` to attach an RSS reading
335
+ /// to each emitted event. Reads `phys_footprint` (or `resident_size`
336
+ /// as a fallback) on the calling thread; safe to call from main.
337
+ func residentBytesPublic() -> UInt64 {
338
+ return residentBytes()
339
+ }
340
+
341
+ // Primary path is `phys_footprint` (TASK_VM_INFO) — the same metric
342
+ // iOS jetsam uses for pressure decisions; it includes IOKit
343
+ // accounting and dirty pages credited to this process. The fallback
344
+ // reads `resident_size` (MACH_TASK_BASIC_INFO), which is the raw
345
+ // resident-page count and is semantically smaller and noisier.
346
+ //
347
+ // The two values are NOT interchangeable: if TASK_VM_INFO ever fails
348
+ // mid-run, live readings will step DOWN on the next sample without
349
+ // the underlying memory state having actually changed. TASK_VM_INFO
350
+ // is well supported on iOS 12+, so this is largely defensive — but
351
+ // the discontinuity is worth knowing about when reading the logs.
273
352
  private func residentBytes() -> UInt64 {
274
353
  var vmInfo = task_vm_info_data_t()
275
354
  var count = mach_msg_type_number_t(
@@ -442,23 +521,60 @@ private final class OverlayPassthroughWindow: UIWindow {
442
521
  }
443
522
  }
444
523
 
524
+ /// Host VC for the overlay window. Exists only to give us a
525
+ /// `viewWillTransition(to:with:)` hook so the label can be re-clamped
526
+ /// after rotation / split-screen size changes. Without this, after a
527
+ /// rotation the label's center could land outside the new parent.bounds
528
+ /// and the user would have to drag it back before the pan-clamp kicks in.
529
+ private final class OverlayHostViewController: UIViewController {
530
+ var onTransitionComplete: (() -> Void)?
531
+
532
+ override func viewWillTransition(
533
+ to size: CGSize,
534
+ with coordinator: UIViewControllerTransitionCoordinator
535
+ ) {
536
+ super.viewWillTransition(to: size, with: coordinator)
537
+ coordinator.animate(alongsideTransition: nil) { [weak self] _ in
538
+ self?.onTransitionComplete?()
539
+ }
540
+ }
541
+ }
542
+
445
543
  private final class Overlay: NSObject {
446
544
  static let shared = Overlay()
447
545
 
448
- private override init() { super.init() }
546
+ private override init() {
547
+ super.init()
548
+ // Process-wide observer for scene teardown (iPadOS / Vision Pro
549
+ // multi-window, Mac Catalyst). When the user closes the scene
550
+ // hosting the overlay, our UIWindow ends up referencing a
551
+ // disconnected UIWindowScene — subsequent isHidden / rootVC
552
+ // mutations are at best no-ops and at worst crash on certain
553
+ // iOS releases. Detach references so the next show() can attach
554
+ // cleanly to whichever scene is foreground.
555
+ NotificationCenter.default.addObserver(
556
+ self,
557
+ selector: #selector(handleSceneDisconnect(_:)),
558
+ name: UIScene.didDisconnectNotification,
559
+ object: nil
560
+ )
561
+ }
449
562
 
450
563
  private var label: UILabel?
451
564
  private var overlayWindow: UIWindow?
452
- private var visible = false
453
565
 
454
566
  // `_lastSample` is written by the Sampler timer thread and read by the
455
567
  // main thread in attach() and inside the coalesced update closure.
456
568
  // Optional<struct> is not atomic in Swift, so guard with a lock to avoid
457
- // torn reads / undefined behaviour. `_updatePending` is part of the same
458
- // protected state to coalesce overlay refreshes.
569
+ // torn reads / undefined behaviour. `_updatePending` and `_visible` join
570
+ // the same lock: _updatePending coalesces overlay refreshes; _visible is
571
+ // toggled by show()/hide() on the caller's thread but read in update()'s
572
+ // main-thread closure, so snapshotting it under the existing lock keeps
573
+ // the read race-free without a second lock.
459
574
  private let sampleLock = NSLock()
460
575
  private var _lastSample: PerfSample?
461
576
  private var _updatePending = false
577
+ private var _visible = false
462
578
 
463
579
  private var lastSample: PerfSample? {
464
580
  get {
@@ -471,6 +587,17 @@ private final class Overlay: NSObject {
471
587
  }
472
588
  }
473
589
 
590
+ private var visible: Bool {
591
+ get {
592
+ sampleLock.lock(); defer { sampleLock.unlock() }
593
+ return _visible
594
+ }
595
+ set {
596
+ sampleLock.lock(); defer { sampleLock.unlock() }
597
+ _visible = newValue
598
+ }
599
+ }
600
+
474
601
  func show() {
475
602
  visible = true
476
603
  DispatchQueue.main.async { [weak self] in self?.attach() }
@@ -500,8 +627,9 @@ private final class Overlay: NSObject {
500
627
  self.sampleLock.lock()
501
628
  self._updatePending = false
502
629
  let snapshot = self._lastSample
630
+ let isVisible = self._visible
503
631
  self.sampleLock.unlock()
504
- guard self.visible, let snapshot = snapshot else { return }
632
+ guard isVisible, let snapshot = snapshot else { return }
505
633
  self.label?.text = self.renderText(snapshot)
506
634
  }
507
635
  }
@@ -516,8 +644,13 @@ private final class Overlay: NSObject {
516
644
  // Dedicated host VC keeps the window's view hierarchy minimal —
517
645
  // the empty UIView serves only as the parent for the label and
518
646
  // as the hitTest sentinel checked by OverlayPassthroughWindow.
519
- let host = UIViewController()
647
+ // The subclass overrides viewWillTransition so the label gets
648
+ // re-clamped after rotation / split-screen size changes.
649
+ let host = OverlayHostViewController()
520
650
  host.view.backgroundColor = .clear
651
+ host.onTransitionComplete = { [weak self] in
652
+ self?.clampLabelToBounds()
653
+ }
521
654
 
522
655
  let window = OverlayPassthroughWindow(windowScene: scene)
523
656
  window.frame = scene.coordinateSpace.bounds
@@ -580,6 +713,35 @@ private final class Overlay: NSObject {
580
713
  gesture.setTranslation(.zero, in: parent)
581
714
  }
582
715
 
716
+ /// Re-clamp the label to its parent.bounds. Called after rotation /
717
+ /// size-class change so the persisted center doesn't leave the
718
+ /// label half-off-screen until the next pan triggers handlePan.
719
+ private func clampLabelToBounds() {
720
+ guard let lbl = label, let parent = lbl.superview else { return }
721
+ let parentW = parent.bounds.size.width
722
+ let parentH = parent.bounds.size.height
723
+ if parentW <= 0 || parentH <= 0 { return }
724
+ let halfW = lbl.bounds.size.width / 2
725
+ let halfH = lbl.bounds.size.height / 2
726
+ lbl.center = CGPoint(
727
+ x: max(halfW, min(parentW - halfW, lbl.center.x)),
728
+ y: max(halfH, min(parentH - halfH, lbl.center.y))
729
+ )
730
+ }
731
+
732
+ /// Fires when a UIWindowScene disconnects (multi-window close on
733
+ /// iPad / Vision / Mac Catalyst). If it's the scene our window is
734
+ /// attached to, drop our references so the next show() rebuilds
735
+ /// against the new foreground scene. UIScene notifications post on
736
+ /// the main thread, so this matches the threading invariants of
737
+ /// attach/detach.
738
+ @objc private func handleSceneDisconnect(_ note: Notification) {
739
+ guard let scene = note.object as? UIWindowScene else { return }
740
+ if overlayWindow?.windowScene === scene {
741
+ detach()
742
+ }
743
+ }
744
+
583
745
  private func renderText(_ s: PerfSample) -> String {
584
746
  let cpuStr = s.cpu > 0 ? String(format: "%.1f%%", s.cpu) : "--"
585
747
  let mb = s.rss / 1024.0 / 1024.0
@@ -605,3 +767,186 @@ private final class Overlay: NSObject {
605
767
  return fallback
606
768
  }
607
769
  }
770
+
771
+ // MARK: - MemoryWarningCenter
772
+ //
773
+ // Bridges UIKit's memory-warning notification to Nitro callbacks.
774
+ //
775
+ // iOS only emits one level — `UIApplicationDidReceiveMemoryWarningNotification`
776
+ // — which we map to `critical`. There is no `low` analog on iOS, by design
777
+ // (`MemoryWarningEvent.level` is normalised across platforms).
778
+ //
779
+ // The observer is registered lazily on the first `add(callback:)` and kept
780
+ // for the process lifetime. iOS guarantees a single NotificationCenter
781
+ // callback per notification, so cost is negligible. Callbacks fire on the
782
+ // main thread because that's the queue UIApplication posts on; Nitro's
783
+ // dispatcher will hop back to the JS thread.
784
+
785
+ /// 500ms cleanup throttle. UIApplication usually coalesces repeated
786
+ /// didReceiveMemoryWarning posts but doesn't guarantee it; without a
787
+ /// gate, a burst would re-run URLCache.removeAllCachedResponses
788
+ /// (no-op), WK clear (kicks off a redundant async run) and a 100-500ms
789
+ /// malloc zone walk back-to-back. Mirrors Android's emit() dedupe
790
+ /// window in MemoryWarningCenter.kt.
791
+ private let kCleanupDedupSec: Double = 0.5
792
+
793
+ /// `malloc_zone_pressure_relief(nil, 0)` walks every registered libmalloc
794
+ /// zone and can block 100-500ms on an 800MB+ heap. Hosted off-main on a
795
+ /// userInitiated queue so the OS memory-warning observer chain returns
796
+ /// promptly. File-scope private so all MemoryWarningCenter calls share
797
+ /// the queue.
798
+ private let perfStatsCleanupQueue = DispatchQueue(
799
+ label: "io.onekey.perfstats.cleanup",
800
+ qos: .userInitiated
801
+ )
802
+
803
+ private final class MemoryWarningCenter: NSObject {
804
+ static let shared = MemoryWarningCenter()
805
+
806
+ private override init() { super.init() }
807
+
808
+ private let lock = NSLock()
809
+ private var listeners: [Int: (MemoryWarningEvent) -> Void] = [:]
810
+ private var nextId: Int = 1
811
+ private var observed = false
812
+ /// Monotonic seconds of the last cleanup run, guarded by `lock`.
813
+ /// Survives the process lifetime to honour the dedup window across
814
+ /// rapid back-to-back memory warnings.
815
+ private var lastCleanupSec: Double = 0
816
+
817
+ func add(callback: @escaping (MemoryWarningEvent) -> Void) -> Int {
818
+ lock.lock()
819
+ let id = nextId
820
+ nextId += 1
821
+ listeners[id] = callback
822
+ if !observed {
823
+ // Register synchronously inside the lock so a memory warning
824
+ // posted between `add` returning and the observer being attached
825
+ // cannot slip past the caller. NotificationCenter.addObserver
826
+ // has no main-thread requirement; the selector is dispatched on
827
+ // whichever thread posts the notification (UIApplication posts
828
+ // on main, so handleWarning still lands there).
829
+ NotificationCenter.default.addObserver(
830
+ self,
831
+ selector: #selector(self.handleWarning),
832
+ name: UIApplication.didReceiveMemoryWarningNotification,
833
+ object: nil
834
+ )
835
+ observed = true
836
+ }
837
+ lock.unlock()
838
+ return id
839
+ }
840
+
841
+ func remove(id: Int) {
842
+ lock.lock()
843
+ listeners.removeValue(forKey: id)
844
+ lock.unlock()
845
+ }
846
+
847
+ @objc private func handleWarning() {
848
+ let rssBefore = Sampler.shared.residentBytesPublic()
849
+ let event = MemoryWarningEvent(
850
+ level: .critical,
851
+ rss: Double(rssBefore),
852
+ timestamp: Date().timeIntervalSince1970 * 1000.0
853
+ )
854
+ OneKeyLog.warn(kTag, String(
855
+ format: "Memory warning received (critical), RSS=%.1f MB",
856
+ event.rss / 1024.0 / 1024.0
857
+ ))
858
+
859
+ // Throttle native cleanup but never the JS notification: the OS
860
+ // can post a second warning within milliseconds of the first
861
+ // (UIApplication "usually coalesces" — not guaranteed), and the
862
+ // second one's URLCache + WK + malloc relief would all be
863
+ // redundant work. JS handlers still get every signal because
864
+ // they may want to know the pressure persists even after a
865
+ // skipped cleanup.
866
+ let nowSec = monotonicSec()
867
+ lock.lock()
868
+ let shouldClean = nowSec - lastCleanupSec >= kCleanupDedupSec
869
+ if shouldClean { lastCleanupSec = nowSec }
870
+ lock.unlock()
871
+
872
+ if shouldClean {
873
+ performNativeCleanup(rssBefore: rssBefore)
874
+ }
875
+
876
+ lock.lock()
877
+ let snapshot = Array(listeners.values)
878
+ lock.unlock()
879
+ for cb in snapshot { cb(event) }
880
+ }
881
+
882
+ /// Three reclaim paths, ordered from cheap-and-safe to system-level:
883
+ ///
884
+ /// 1. `URLCache.shared.removeAllCachedResponses()` — drops the
885
+ /// process-wide HTTP response cache (CFNetwork). Empirically the
886
+ /// largest single non-WebView pool, often 50–200 MB. **Note:**
887
+ /// this is process-wide, so any code path relying on URLCache
888
+ /// for offline content (some image libraries, default
889
+ /// URLSessionConfiguration) loses its cached responses; the
890
+ /// next request goes to network. Synchronous, ~few ms.
891
+ /// 2. `WKWebsiteDataStore.removeData(...)` for HTTP caches and the
892
+ /// AppCache/ServiceWorker store. Excludes cookies / localStorage
893
+ /// / IndexedDB so in-WebView auth survives. **AppCache and
894
+ /// Service Worker registrations ARE in scope** — PWAs that rely
895
+ /// on a Service Worker for offline fallback will lose that until
896
+ /// the SW re-registers on next navigation. Asynchronous, runs on
897
+ /// a WebKit private queue; the reclaim is not visible in the
898
+ /// rssBefore/rssAfter delta logged below.
899
+ /// 3. `malloc_zone_pressure_relief(nil, 0)` — asks libmalloc to
900
+ /// walk every registered zone and return free pages to the
901
+ /// kernel. This is the only API that actually drops
902
+ /// `phys_footprint`; the previous two reduce allocator usage but
903
+ /// pages stay mapped until pressure relief runs. **Can block
904
+ /// 100-500ms on a heap of 800MB+**, so it's dispatched to a
905
+ /// background queue.
906
+ ///
907
+ /// - parameter rssBefore: baseline for the reclaim-delta log. Pass
908
+ /// non-nil from the memory-warning path so the bg-dispatched
909
+ /// `OneKeyLog.warn` shows "before/after"; pass nil for caller-
910
+ /// initiated cleanups (`cleanupNativeCaches`) to skip the log.
911
+ func performNativeCleanup(rssBefore: UInt64? = nil) {
912
+ URLCache.shared.removeAllCachedResponses()
913
+
914
+ let cacheTypes: Set<String> = [
915
+ WKWebsiteDataTypeMemoryCache,
916
+ WKWebsiteDataTypeDiskCache,
917
+ WKWebsiteDataTypeOfflineWebApplicationCache,
918
+ ]
919
+ WKWebsiteDataStore.default().removeData(
920
+ ofTypes: cacheTypes,
921
+ modifiedSince: Date(timeIntervalSince1970: 0),
922
+ completionHandler: {}
923
+ )
924
+
925
+ perfStatsCleanupQueue.async {
926
+ malloc_zone_pressure_relief(nil, 0)
927
+ guard let before = rssBefore else { return }
928
+ let rssAfter = Sampler.shared.residentBytesPublic()
929
+ let deltaMB = (Double(before) - Double(rssAfter)) / 1024.0 / 1024.0
930
+ if deltaMB > 0.5 {
931
+ OneKeyLog.warn(kTag, String(
932
+ format: "Native cleanup reclaimed %.1f MB (RSS %.1f → %.1f, " +
933
+ "excludes WK async reclaim which completes later)",
934
+ deltaMB,
935
+ Double(before) / 1024.0 / 1024.0,
936
+ Double(rssAfter) / 1024.0 / 1024.0
937
+ ))
938
+ }
939
+ }
940
+ }
941
+
942
+ /// Local monotonic helper. `Sampler.monotonicSec` is private; rather
943
+ /// than widening its visibility for one caller, we inline the same
944
+ /// `clock_gettime(CLOCK_MONOTONIC)` lookup here.
945
+ private func monotonicSec() -> Double {
946
+ var ts = timespec()
947
+ if clock_gettime(CLOCK_MONOTONIC, &ts) != 0 {
948
+ return Date().timeIntervalSince1970
949
+ }
950
+ return Double(ts.tv_sec) + Double(ts.tv_nsec) / 1_000_000_000.0
951
+ }
952
+ }
@@ -24,6 +24,11 @@ let jsFpsIntervalId = null;
24
24
  let jsFpsFrameCount = 0;
25
25
  let jsFpsCurrentInterval = null;
26
26
 
27
+ // Module-level latch so forceGarbageCollection's "no GC binding" branch
28
+ // warns exactly once per JS realm. Avoids spamming the console when a
29
+ // memory-warning handler calls forceGarbageCollection on every event.
30
+ let forceGcMissingWarned = false;
31
+
27
32
  /**
28
33
  * Start the JS-side FPS ticker. Normally invoked automatically by
29
34
  * `ReactNativePerfStats.start` and stopped by `.stop`; exported as
@@ -35,19 +40,25 @@ let jsFpsCurrentInterval = null;
35
40
  * interval is a no-op.
36
41
  */
37
42
  export function startJsFpsTracker(reportIntervalMs = 1000) {
38
- if (jsFpsCurrentInterval === reportIntervalMs) return;
43
+ // Clamp before use: `setInterval(_, 0)` fires every event-loop tick and
44
+ // the per-second divide below would race away to Infinity; NaN/negative
45
+ // values are equally meaningless here. 100 ms is below one rAF frame
46
+ // on a 60 Hz display, so it's already finer than the data warrants;
47
+ // 60 s is an arbitrary upper sanity bound.
48
+ const safeInterval = Number.isFinite(reportIntervalMs) ? Math.max(100, Math.min(60_000, Math.trunc(reportIntervalMs))) : 1000;
49
+ if (jsFpsCurrentInterval === safeInterval) return;
39
50
  stopJsFpsTracker();
40
- jsFpsCurrentInterval = reportIntervalMs;
51
+ jsFpsCurrentInterval = safeInterval;
41
52
  const tick = () => {
42
53
  jsFpsFrameCount += 1;
43
54
  jsFpsRafId = requestAnimationFrame(tick);
44
55
  };
45
56
  jsFpsRafId = requestAnimationFrame(tick);
46
57
  jsFpsIntervalId = setInterval(() => {
47
- const fps = jsFpsFrameCount * 1000 / reportIntervalMs;
58
+ const fps = jsFpsFrameCount * 1000 / safeInterval;
48
59
  jsFpsFrameCount = 0;
49
60
  nativeImpl.setJsFpsHint(fps);
50
- }, reportIntervalMs);
61
+ }, safeInterval);
51
62
  }
52
63
 
53
64
  /** Stop the JS-side FPS ticker. Idempotent. */
@@ -82,6 +93,67 @@ export const ReactNativePerfStats = {
82
93
  showOverlay: () => nativeImpl.showOverlay(),
83
94
  hideOverlay: () => nativeImpl.hideOverlay(),
84
95
  sample: () => nativeImpl.sample(),
85
- setJsFpsHint: fps => nativeImpl.setJsFpsHint(fps)
96
+ setJsFpsHint: fps => nativeImpl.setJsFpsHint(fps),
97
+ // Memory pressure is independent of the sampler — these stay active
98
+ // even after `stop()`. iOS only emits `level: 'critical'`.
99
+ addMemoryWarningListener: callback => nativeImpl.addMemoryWarningListener(callback),
100
+ removeMemoryWarningListener: id => nativeImpl.removeMemoryWarningListener(id),
101
+ /**
102
+ * Run the same native reclaim path the OS memory-warning observer
103
+ * triggers, on demand. Returns immediately (heavy work runs async
104
+ * on iOS). See the spec doc for what gets dropped on each platform.
105
+ */
106
+ cleanupNativeCaches: () => nativeImpl.cleanupNativeCaches(),
107
+ /**
108
+ * Best-effort hint to the JS engine that now is a good time to GC.
109
+ *
110
+ * Hermes does not expose a public `collectGarbage` binding in production
111
+ * builds; the only stable JS-level entry point is the (undocumented)
112
+ * `HermesInternal.gc` property, which is present in some builds and
113
+ * absent in others. We feature-detect it and fall back to a no-op so
114
+ * callers never have to branch.
115
+ *
116
+ * Returns `true` only if a GC binding was both found AND invoked
117
+ * without throwing. A `false` return therefore covers three cases —
118
+ * binding missing (production Hermes is the common case), binding
119
+ * present but threw, and any unexpected failure. The first miss is
120
+ * logged once via `console.warn` so the caller knows it landed in the
121
+ * "no binding" branch; throws are logged on every occurrence because
122
+ * those are real errors.
123
+ *
124
+ * Cost: when honoured, Hermes does a stop-the-world collection that
125
+ * can take 100–500 ms — never call this on the hot path. Memory-warning
126
+ * handlers are the intended use case.
127
+ */
128
+ forceGarbageCollection() {
129
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
130
+ const g = globalThis;
131
+ const hi = g?.HermesInternal;
132
+ if (hi && typeof hi.gc === 'function') {
133
+ try {
134
+ hi.gc();
135
+ return true;
136
+ } catch (e) {
137
+ console.warn('[PerfStats] HermesInternal.gc threw:', e);
138
+ return false;
139
+ }
140
+ }
141
+ if (typeof g?.gc === 'function') {
142
+ // V8-style binding (only present with --expose-gc; never in
143
+ // production Hermes, but harmless to try).
144
+ try {
145
+ g.gc();
146
+ return true;
147
+ } catch (e) {
148
+ console.warn('[PerfStats] globalThis.gc threw:', e);
149
+ return false;
150
+ }
151
+ }
152
+ if (!forceGcMissingWarned) {
153
+ forceGcMissingWarned = true;
154
+ console.warn('[PerfStats] forceGarbageCollection: no GC binding found ' + '(HermesInternal.gc / globalThis.gc absent). Production Hermes ' + 'strips these; this warning fires once per JS realm.');
155
+ }
156
+ return false;
157
+ }
86
158
  };
87
159
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"names":["NitroModules","nativeImpl","createHybridObject","jsFpsRafId","jsFpsIntervalId","jsFpsFrameCount","jsFpsCurrentInterval","startJsFpsTracker","reportIntervalMs","stopJsFpsTracker","tick","requestAnimationFrame","setInterval","fps","setJsFpsHint","cancelAnimationFrame","clearInterval","ReactNativePerfStats","start","intervalMs","stop","showOverlay","hideOverlay","sample"],"sourceRoot":"../../src","sources":["index.tsx"],"mappings":";;AAAA,SAASA,YAAY,QAAQ,4BAA4B;AAGzD,MAAMC,UAAU,GACdD,YAAY,CAACE,kBAAkB,CAA2B,sBAAsB,CAAC;AAInF;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA,IAAIC,UAAyB,GAAG,IAAI;AACpC,IAAIC,eAAsD,GAAG,IAAI;AACjE,IAAIC,eAAe,GAAG,CAAC;AACvB,IAAIC,oBAAmC,GAAG,IAAI;;AAE9C;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,OAAO,SAASC,iBAAiBA,CAACC,gBAAwB,GAAG,IAAI,EAAQ;EACvE,IAAIF,oBAAoB,KAAKE,gBAAgB,EAAE;EAC/CC,gBAAgB,CAAC,CAAC;EAClBH,oBAAoB,GAAGE,gBAAgB;EAEvC,MAAME,IAAI,GAAGA,CAAA,KAAM;IACjBL,eAAe,IAAI,CAAC;IACpBF,UAAU,GAAGQ,qBAAqB,CAACD,IAAI,CAAC;EAC1C,CAAC;EACDP,UAAU,GAAGQ,qBAAqB,CAACD,IAAI,CAAC;EAExCN,eAAe,GAAGQ,WAAW,CAAC,MAAM;IAClC,MAAMC,GAAG,GAAIR,eAAe,GAAG,IAAI,GAAIG,gBAAgB;IACvDH,eAAe,GAAG,CAAC;IACnBJ,UAAU,CAACa,YAAY,CAACD,GAAG,CAAC;EAC9B,CAAC,EAAEL,gBAAgB,CAAC;AACtB;;AAEA;AACA,OAAO,SAASC,gBAAgBA,CAAA,EAAS;EACvC,IAAIN,UAAU,IAAI,IAAI,EAAE;IACtBY,oBAAoB,CAACZ,UAAU,CAAC;IAChCA,UAAU,GAAG,IAAI;EACnB;EACA,IAAIC,eAAe,IAAI,IAAI,EAAE;IAC3BY,aAAa,CAACZ,eAAe,CAAC;IAC9BA,eAAe,GAAG,IAAI;EACxB;EACAC,eAAe,GAAG,CAAC;EACnBC,oBAAoB,GAAG,IAAI;AAC7B;;AAEA;AACA;AACA;AACA;AACA;;AAEA,OAAO,MAAMW,oBAAoB,GAAG;EAClCC,KAAKA,CAACC,UAAkB,EAAQ;IAC9BlB,UAAU,CAACiB,KAAK,CAACC,UAAU,CAAC;IAC5BZ,iBAAiB,CAACY,UAAU,CAAC;EAC/B,CAAC;EACDC,IAAIA,CAAA,EAAS;IACXX,gBAAgB,CAAC,CAAC;IAClBR,UAAU,CAACmB,IAAI,CAAC,CAAC;EACnB,CAAC;EACDC,WAAW,EAAEA,CAAA,KAAYpB,UAAU,CAACoB,WAAW,CAAC,CAAC;EACjDC,WAAW,EAAEA,CAAA,KAAYrB,UAAU,CAACqB,WAAW,CAAC,CAAC;EACjDC,MAAM,EAAEA,CAAA,KAAMtB,UAAU,CAACsB,MAAM,CAAC,CAAC;EACjCT,YAAY,EAAGD,GAAW,IAAWZ,UAAU,CAACa,YAAY,CAACD,GAAG;AAClE,CAAC","ignoreList":[]}
1
+ {"version":3,"names":["NitroModules","nativeImpl","createHybridObject","jsFpsRafId","jsFpsIntervalId","jsFpsFrameCount","jsFpsCurrentInterval","forceGcMissingWarned","startJsFpsTracker","reportIntervalMs","safeInterval","Number","isFinite","Math","max","min","trunc","stopJsFpsTracker","tick","requestAnimationFrame","setInterval","fps","setJsFpsHint","cancelAnimationFrame","clearInterval","ReactNativePerfStats","start","intervalMs","stop","showOverlay","hideOverlay","sample","addMemoryWarningListener","callback","removeMemoryWarningListener","id","cleanupNativeCaches","forceGarbageCollection","g","globalThis","hi","HermesInternal","gc","e","console","warn"],"sourceRoot":"../../src","sources":["index.tsx"],"mappings":"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","ignoreList":[]}