heapscope 0.6.0

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 (110) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +119 -0
  3. data/CHANGELOG.md +86 -0
  4. data/CODE_OF_CONDUCT.md +38 -0
  5. data/CONTRIBUTING.md +72 -0
  6. data/Gemfile +11 -0
  7. data/LICENSE +21 -0
  8. data/README.md +342 -0
  9. data/Rakefile +49 -0
  10. data/SECURITY.md +39 -0
  11. data/assets/heapscope-logo.png +0 -0
  12. data/assets/logo.svg +24 -0
  13. data/assets/wordmark.svg +18 -0
  14. data/docs/README.md +20 -0
  15. data/docs/ROADMAP.md +29 -0
  16. data/docs/adr/0001-evidence-over-certainty.md +24 -0
  17. data/docs/adr/0002-local-only-privacy.md +19 -0
  18. data/docs/adr/0003-runtime-adapters.md +25 -0
  19. data/docs/adr/README.md +7 -0
  20. data/docs/allocators.md +13 -0
  21. data/docs/api/overview.md +70 -0
  22. data/docs/changelogs/0.1.0.md +22 -0
  23. data/docs/changelogs/0.2.0.md +49 -0
  24. data/docs/changelogs/0.3.0.md +38 -0
  25. data/docs/changelogs/0.4.0.md +32 -0
  26. data/docs/changelogs/0.5.0.md +18 -0
  27. data/docs/changelogs/0.6.0.md +23 -0
  28. data/docs/cli.md +98 -0
  29. data/docs/diagnostics/HS001_persistent_class_growth.md +39 -0
  30. data/docs/diagnostics/HS002_high_retention_ratio.md +24 -0
  31. data/docs/diagnostics/HS003_thread_local_retention.md +24 -0
  32. data/docs/diagnostics/HS004_unbounded_collection.md +19 -0
  33. data/docs/diagnostics/HS005_callback_accumulation.md +14 -0
  34. data/docs/diagnostics/HS006_closure_retention.md +15 -0
  35. data/docs/diagnostics/HS007_poor_gc_recovery.md +18 -0
  36. data/docs/diagnostics/HS008_baseline_regression.md +15 -0
  37. data/docs/diagnostics/HS009_high_allocation_pressure.md +13 -0
  38. data/docs/diagnostics/HS010_native_memory_mismatch.md +19 -0
  39. data/docs/guides/ci-budgets.md +43 -0
  40. data/docs/guides/first-retention-experiment.md +25 -0
  41. data/docs/guides/production-safe.md +17 -0
  42. data/docs/index.md +38 -0
  43. data/examples/cache_vs_leak.rb +30 -0
  44. data/examples/closure_capture.rb +20 -0
  45. data/examples/healthy_churn.rb +16 -0
  46. data/examples/heapscope.yml +12 -0
  47. data/examples/import_leak.rb +19 -0
  48. data/examples/probe_and_session.rb +22 -0
  49. data/examples/thread_local_leak.rb +31 -0
  50. data/exe/heapscope +7 -0
  51. data/heapscope.gemspec +73 -0
  52. data/lib/heapscope/aging.rb +114 -0
  53. data/lib/heapscope/analyzer.rb +333 -0
  54. data/lib/heapscope/baseline.rb +77 -0
  55. data/lib/heapscope/branding.rb +98 -0
  56. data/lib/heapscope/budget.rb +108 -0
  57. data/lib/heapscope/capabilities.rb +42 -0
  58. data/lib/heapscope/catalog.rb +71 -0
  59. data/lib/heapscope/cli/color.rb +67 -0
  60. data/lib/heapscope/cli/commands/capture.rb +289 -0
  61. data/lib/heapscope/cli/commands/diffing.rb +157 -0
  62. data/lib/heapscope/cli/commands/meta.rb +244 -0
  63. data/lib/heapscope/cli/commands/reporting.rb +228 -0
  64. data/lib/heapscope/cli/completion.rb +72 -0
  65. data/lib/heapscope/cli/help.rb +226 -0
  66. data/lib/heapscope/cli/support.rb +126 -0
  67. data/lib/heapscope/cli.rb +165 -0
  68. data/lib/heapscope/closures.rb +102 -0
  69. data/lib/heapscope/collector.rb +167 -0
  70. data/lib/heapscope/config.rb +196 -0
  71. data/lib/heapscope/detectors.rb +131 -0
  72. data/lib/heapscope/diff.rb +130 -0
  73. data/lib/heapscope/dominators.rb +87 -0
  74. data/lib/heapscope/errors.rb +13 -0
  75. data/lib/heapscope/extrapolation.rb +51 -0
  76. data/lib/heapscope/findings.rb +149 -0
  77. data/lib/heapscope/globals.rb +108 -0
  78. data/lib/heapscope/graph.rb +218 -0
  79. data/lib/heapscope/growth.rb +108 -0
  80. data/lib/heapscope/middleware.rb +35 -0
  81. data/lib/heapscope/minitest.rb +47 -0
  82. data/lib/heapscope/monitor.rb +160 -0
  83. data/lib/heapscope/noise.rb +48 -0
  84. data/lib/heapscope/notifications.rb +79 -0
  85. data/lib/heapscope/pack.rb +55 -0
  86. data/lib/heapscope/paths.rb +64 -0
  87. data/lib/heapscope/rails.rb +71 -0
  88. data/lib/heapscope/report/html.rb +283 -0
  89. data/lib/heapscope/report/markdown.rb +78 -0
  90. data/lib/heapscope/report/text.rb +204 -0
  91. data/lib/heapscope/report.rb +308 -0
  92. data/lib/heapscope/retention.rb +91 -0
  93. data/lib/heapscope/rspec.rb +118 -0
  94. data/lib/heapscope/runtime/base.rb +130 -0
  95. data/lib/heapscope/runtime/jruby.rb +28 -0
  96. data/lib/heapscope/runtime/mri.rb +86 -0
  97. data/lib/heapscope/runtime/truffleruby.rb +40 -0
  98. data/lib/heapscope/runtime/windows_rss.rb +75 -0
  99. data/lib/heapscope/runtime.rb +33 -0
  100. data/lib/heapscope/schema.rb +28 -0
  101. data/lib/heapscope/scorecard.rb +85 -0
  102. data/lib/heapscope/session.rb +90 -0
  103. data/lib/heapscope/sidekiq_middleware.rb +36 -0
  104. data/lib/heapscope/snapshot.rb +165 -0
  105. data/lib/heapscope/suggest.rb +99 -0
  106. data/lib/heapscope/tables.rb +58 -0
  107. data/lib/heapscope/trend_store.rb +41 -0
  108. data/lib/heapscope/version.rb +6 -0
  109. data/lib/heapscope.rb +307 -0
  110. metadata +211 -0
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module HeapScope
7
+ # Versioned report combining snapshots, diffs, findings, and presentation.
8
+ class Report
9
+ attr_reader :schema_version, :heapscope_version, :runtime_info, :summary,
10
+ :before, :after, :diff, :findings, :suspects, :classes,
11
+ :allocation_sites, :retention, :metadata, :limitations,
12
+ :budget_result, :thread_locals, :reproduction,
13
+ :fibers, :globals, :closures, :dominators, :fragmentation,
14
+ :aging, :gc_correlation, :retention_paths
15
+
16
+ def initialize(**attrs)
17
+ @schema_version = attrs[:schema_version] || SCHEMA_VERSION
18
+ @heapscope_version = attrs[:heapscope_version] || VERSION
19
+ @runtime_info = attrs[:runtime_info] || {}
20
+ @summary = attrs[:summary] || {}
21
+ @before = attrs[:before]
22
+ @after = attrs[:after]
23
+ @diff = attrs[:diff]
24
+ @findings = attrs[:findings] || []
25
+ @suspects = attrs[:suspects] || []
26
+ @classes = attrs[:classes] || []
27
+ @allocation_sites = attrs[:allocation_sites] || []
28
+ @retention = attrs[:retention]
29
+ @metadata = attrs[:metadata] || {}
30
+ @limitations = attrs[:limitations] || []
31
+ @budget_result = attrs[:budget_result]
32
+ @thread_locals = attrs[:thread_locals]
33
+ @reproduction = attrs[:reproduction]
34
+ @fibers = attrs[:fibers]
35
+ @globals = attrs[:globals]
36
+ @closures = attrs[:closures]
37
+ @dominators = attrs[:dominators]
38
+ @fragmentation = attrs[:fragmentation]
39
+ @aging = attrs[:aging]
40
+ @gc_correlation = attrs[:gc_correlation]
41
+ @retention_paths = attrs[:retention_paths]
42
+ end
43
+
44
+ def healthy?
45
+ summary[:healthy] == true || (findings.empty? && suspects.none? { |s| s[:severity] == :high })
46
+ end
47
+
48
+ def retained_count_for(klass)
49
+ name = klass.is_a?(Module) ? klass.name : klass.to_s
50
+ row = classes.find { |c| c[:name] == name || c["name"] == name }
51
+ return row[:delta_count] || row["delta_count"] if row
52
+
53
+ return 0 unless diff
54
+
55
+ delta = diff.class_deltas.find { |c| c[:name] == name }
56
+ delta ? delta[:delta_count] : 0
57
+ end
58
+
59
+ def retained_bytes_for(klass)
60
+ name = klass.is_a?(Module) ? klass.name : klass.to_s
61
+ return 0 unless diff
62
+
63
+ delta = diff.class_deltas.find { |c| c[:name] == name }
64
+ delta ? delta[:delta_bytes] : 0
65
+ end
66
+
67
+ def passed_budget?
68
+ budget_result.nil? || budget_result[:passed]
69
+ end
70
+
71
+ def print(io = $stdout)
72
+ io.puts to_text
73
+ self
74
+ end
75
+
76
+ def to_s
77
+ to_text
78
+ end
79
+
80
+ def to_text
81
+ Report::Text.render(self)
82
+ end
83
+
84
+ def to_markdown
85
+ Report::Markdown.render(self)
86
+ end
87
+
88
+ def save_markdown(path)
89
+ File.write(path, to_markdown)
90
+ path
91
+ end
92
+
93
+ def to_h
94
+ {
95
+ schema_version: schema_version,
96
+ heapscope_version: heapscope_version,
97
+ runtime: runtime_info,
98
+ summary: summary,
99
+ metadata: metadata,
100
+ limitations: limitations,
101
+ before: before&.to_h,
102
+ after: after&.to_h,
103
+ diff: diff_hash,
104
+ classes: classes,
105
+ allocation_sites: allocation_sites,
106
+ findings: findings.map { |f| f.respond_to?(:to_h) ? f.to_h : f },
107
+ suspects: suspects,
108
+ retention: retention,
109
+ budget_result: budget_result,
110
+ thread_locals: thread_locals,
111
+ reproduction: reproduction,
112
+ fibers: fibers,
113
+ globals: globals,
114
+ closures: closures,
115
+ dominators: dominators&.map { |d| d.respond_to?(:to_h) ? d.to_h : d },
116
+ fragmentation: fragmentation,
117
+ aging: aging,
118
+ gc_correlation: gc_correlation,
119
+ retention_paths: retention_paths
120
+ }.compact
121
+ end
122
+
123
+ def to_json(*_args)
124
+ JSON.pretty_generate(to_h)
125
+ end
126
+
127
+ def save(path)
128
+ File.write(path, to_json)
129
+ path
130
+ end
131
+
132
+ def scorecard(title: metadata[:title] || metadata["title"] || "HeapScope")
133
+ Scorecard.from_report(self, title: title)
134
+ end
135
+
136
+ def table(format: :ascii)
137
+ return "" unless diff
138
+
139
+ Tables.class_growth(diff, format: format)
140
+ end
141
+
142
+ def findings_table(format: :ascii)
143
+ Tables.findings(self, format: format)
144
+ end
145
+
146
+ def next_steps(limit: 8)
147
+ Suggest.next_steps(self, limit: limit)
148
+ end
149
+
150
+ def ranked_findings
151
+ Findings.rank_and_dedupe(findings)
152
+ end
153
+
154
+ def save_html(path)
155
+ File.write(path, Report::HTML.render(self))
156
+ path
157
+ end
158
+
159
+ def self.load(path)
160
+ data = JSON.parse(File.read(path), symbolize_names: true)
161
+ Schema.validate!(data)
162
+ from_h(data)
163
+ end
164
+
165
+ def self.from_h(data)
166
+ before = data[:before] ? Snapshot.from_h(data[:before]) : nil
167
+ after = data[:after] ? Snapshot.from_h(data[:after]) : nil
168
+ diff = before && after ? Diff.new(before, after) : nil
169
+ findings = Array(data[:findings]).map do |f|
170
+ Finding.new(
171
+ code: f[:code],
172
+ severity: f[:severity]&.to_sym,
173
+ title: f[:title],
174
+ facts: f[:facts],
175
+ derived: f[:derived],
176
+ hypothesis: f[:hypothesis],
177
+ suspected_cause: f[:suspected_cause],
178
+ evidence: f[:evidence],
179
+ suggestions: f[:suggestions],
180
+ subject: f[:subject]
181
+ )
182
+ end
183
+
184
+ new(
185
+ schema_version: data[:schema_version],
186
+ heapscope_version: data[:heapscope_version],
187
+ runtime_info: data[:runtime] || {},
188
+ summary: data[:summary] || {},
189
+ before: before,
190
+ after: after,
191
+ diff: diff,
192
+ findings: findings,
193
+ suspects: data[:suspects] || [],
194
+ classes: data[:classes] || diff&.class_deltas || [],
195
+ allocation_sites: data[:allocation_sites] || [],
196
+ retention: data[:retention],
197
+ metadata: data[:metadata] || {},
198
+ limitations: data[:limitations] || [],
199
+ budget_result: data[:budget_result],
200
+ thread_locals: data[:thread_locals],
201
+ reproduction: data[:reproduction],
202
+ fibers: data[:fibers],
203
+ globals: data[:globals],
204
+ closures: data[:closures],
205
+ dominators: data[:dominators],
206
+ fragmentation: data[:fragmentation],
207
+ aging: data[:aging],
208
+ gc_correlation: data[:gc_correlation],
209
+ retention_paths: data[:retention_paths]
210
+ )
211
+ end
212
+
213
+ def self.from_diff(diff, analysis:, metadata: {})
214
+ limitations = (diff.before.limitations + diff.after.limitations).uniq
215
+ frag = Fragmentation.assess(diff.after)
216
+ new(
217
+ runtime_info: {
218
+ ruby: RUBY_VERSION,
219
+ engine: RUBY_ENGINE,
220
+ platform: RUBY_PLATFORM
221
+ },
222
+ summary: analysis[:summary],
223
+ before: diff.before,
224
+ after: diff.after,
225
+ diff: diff,
226
+ findings: analysis[:findings],
227
+ suspects: analysis[:suspects],
228
+ classes: diff.class_deltas.sort_by { |c| -c[:delta_count].abs },
229
+ allocation_sites: diff.allocation_deltas.first(50),
230
+ fragmentation: frag,
231
+ reproduction: {
232
+ command: Reproduction.command(metadata),
233
+ captured_at: Time.now.utc.iso8601
234
+ },
235
+ metadata: metadata,
236
+ limitations: limitations
237
+ )
238
+ end
239
+
240
+ def self.from_retention_session(session)
241
+ analysis = Analyzer.new.analyze_retention(session)
242
+ first = session.samples.first&.snapshot
243
+ last = session.samples.last&.snapshot
244
+ diff = first && last ? Diff.new(first, last) : nil
245
+ diff_analysis = if diff
246
+ Analyzer.new.analyze_diff(diff,
247
+ context: { force_gc: session.force_gc })
248
+ else
249
+ { findings: [], suspects: [],
250
+ summary: {} }
251
+ end
252
+
253
+ extras = Analyzer.new.enrich_session(session, last)
254
+ all_findings = Findings.rank_and_dedupe(
255
+ analysis[:findings] + diff_analysis[:findings] + Array(extras[:findings])
256
+ )
257
+ new(
258
+ runtime_info: { ruby: RUBY_VERSION, engine: RUBY_ENGINE, platform: RUBY_PLATFORM },
259
+ summary: analysis[:summary].merge(diff_analysis[:summary] || {}).merge(
260
+ healthy: analysis[:persistent].empty? && all_findings.none? { |f| f.severity == :high }
261
+ ),
262
+ before: first,
263
+ after: last,
264
+ diff: diff,
265
+ findings: all_findings,
266
+ suspects: diff_analysis[:suspects],
267
+ classes: diff&.class_deltas || [],
268
+ retention: {
269
+ samples: session.samples.size,
270
+ force_gc: session.force_gc,
271
+ persistent: analysis[:persistent].first(20),
272
+ class_series: analysis[:class_series].values
273
+ .select { |e| e[:delta].abs > 0 }
274
+ .sort_by { |e| -e[:delta].abs }
275
+ .first(50),
276
+ collections: analysis[:collections]
277
+ },
278
+ thread_locals: analysis[:thread_locals],
279
+ fibers: extras[:fibers],
280
+ globals: extras[:globals],
281
+ closures: extras[:closures],
282
+ dominators: extras[:dominators],
283
+ fragmentation: extras[:fragmentation],
284
+ aging: extras[:aging],
285
+ gc_correlation: extras[:gc_correlation],
286
+ retention_paths: extras[:retention_paths],
287
+ reproduction: {
288
+ command: Reproduction.command(kind: "retention_test", cycles: session.samples.size),
289
+ captured_at: Time.now.utc.iso8601
290
+ },
291
+ metadata: { kind: "retention_session" },
292
+ limitations: [first, last].compact.flat_map(&:limitations).uniq + Array(extras[:limitations])
293
+ )
294
+ end
295
+
296
+ private
297
+
298
+ def diff_hash
299
+ return nil unless diff
300
+
301
+ diff.to_h
302
+ end
303
+ end
304
+ end
305
+
306
+ require_relative "report/text"
307
+ require_relative "report/html"
308
+ require_relative "report/markdown"
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ # Multi-cycle retention analysis. Tracks populations that survive GC repeatedly.
5
+ class RetentionSession
6
+ Sample = Struct.new(:index, :snapshot, :after_gc, keyword_init: true)
7
+
8
+ attr_reader :samples, :config, :force_gc
9
+
10
+ def initialize(force_gc: true, mode: :lightweight, metadata: {})
11
+ @force_gc = force_gc
12
+ @mode = mode
13
+ @metadata = metadata
14
+ @samples = []
15
+ @config = HeapScope.config
16
+ @collector = Collector.new
17
+ end
18
+
19
+ def sample(label: nil)
20
+ GC.start if force_gc
21
+ snap = @collector.capture(
22
+ mode: @mode,
23
+ metadata: @metadata.merge(sample_index: @samples.size, label: label)
24
+ )
25
+ @samples << Sample.new(index: @samples.size, snapshot: snap, after_gc: force_gc)
26
+ snap
27
+ end
28
+
29
+ def finish
30
+ Report.from_retention_session(self)
31
+ end
32
+
33
+ def class_series
34
+ names = samples.flat_map { |s| s.snapshot.class_stats.keys }.uniq
35
+ names.each_with_object({}) do |name, hash|
36
+ series = samples.map { |s| s.snapshot.class_count(name) }
37
+ hash[name] = {
38
+ name: name,
39
+ series: series,
40
+ trend: Growth.analyze(series),
41
+ delta: series.last.to_i - series.first.to_i
42
+ }
43
+ end
44
+ end
45
+
46
+ def persistent_classes(min_cycles: 3, min_delta: 10)
47
+ class_series.values.select do |entry|
48
+ trend = entry[:trend]
49
+ next false if entry[:series].size < min_cycles
50
+ next false if entry[:delta] < min_delta
51
+ next false if config.ignore_class?(entry[:name])
52
+
53
+ %i[monotonic_growth linear_growth exponential_like].include?(trend[:pattern])
54
+ end.sort_by { |e| -e[:delta] }
55
+ end
56
+ end
57
+
58
+ # Allocation tracing helpers — opt-in only; never globally enable by surprise.
59
+ class AllocationTracer
60
+ def initialize(runtime: Runtime.current)
61
+ @runtime = runtime
62
+ @active = false
63
+ end
64
+
65
+ def available?
66
+ @runtime.allocation_tracing?
67
+ end
68
+
69
+ def start!
70
+ return false unless available?
71
+
72
+ @runtime.start_allocation_tracing
73
+ @active = true
74
+ true
75
+ end
76
+
77
+ def stop!
78
+ return unless @active
79
+
80
+ @runtime.stop_allocation_tracing
81
+ @active = false
82
+ end
83
+
84
+ def with_tracing
85
+ started = start!
86
+ yield
87
+ ensure
88
+ stop! if started
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "heapscope"
4
+
5
+ # Optional RSpec matchers: require "heapscope/rspec"
6
+ module HeapScope
7
+ module RSpecMatchers
8
+ class RetainLessThan
9
+ def initialize(limit)
10
+ @limit = limit
11
+ @unit = :objects
12
+ @klass = nil
13
+ @report = nil
14
+ end
15
+
16
+ def objects
17
+ @unit = :objects
18
+ self
19
+ end
20
+
21
+ def bytes
22
+ @unit = :bytes
23
+ self
24
+ end
25
+
26
+ def of(klass)
27
+ @klass = klass
28
+ self
29
+ end
30
+
31
+ def matches?(proc)
32
+ @report = HeapScope.measure(force_gc: true) { proc.call }
33
+ @actual =
34
+ if @klass
35
+ @unit == :bytes ? @report.retained_bytes_for(@klass) : @report.retained_count_for(@klass)
36
+ elsif @unit == :bytes
37
+ @report.diff&.heap_bytes_estimate_delta.to_i
38
+ else
39
+ @report.diff&.surviving_estimate.to_i
40
+ end
41
+ @actual < @limit
42
+ end
43
+
44
+ def failure_message
45
+ suspects = Array(@report&.suspects).first(5).map { |s| "#{s[:name]}(+#{s[:delta_count]})" }.join(", ")
46
+ "expected retained #{@unit}#{@klass ? " of #{@klass}" : ""} < #{@limit}, got #{@actual}. Suspects: #{suspects}"
47
+ end
48
+
49
+ def failure_message_when_negated
50
+ "expected retained #{@unit} >= #{@limit}, got #{@actual}"
51
+ end
52
+
53
+ def supports_block_expectations?
54
+ true
55
+ end
56
+ end
57
+
58
+ class GrowHeapByMoreThan
59
+ def initialize(bytes)
60
+ @bytes = bytes
61
+ @report = nil
62
+ end
63
+
64
+ def matches?(proc)
65
+ @report = HeapScope.measure(force_gc: true) { proc.call }
66
+ @actual = @report.diff&.heap_bytes_estimate_delta.to_i
67
+ @actual > @bytes
68
+ end
69
+
70
+ def failure_message
71
+ "expected heap growth > #{@bytes}, got #{@actual}"
72
+ end
73
+
74
+ def failure_message_when_negated
75
+ suspects = Array(@report&.suspects).first(5).map { |s| "#{s[:name]}(+#{s[:delta_count]})" }.join(", ")
76
+ "expected heap not to grow by more than #{@bytes}, got #{@actual}. Suspects: #{suspects}"
77
+ end
78
+
79
+ def supports_block_expectations?
80
+ true
81
+ end
82
+ end
83
+
84
+ class RemainHealthy
85
+ def matches?(proc)
86
+ @report = HeapScope.measure(force_gc: true) { proc.call }
87
+ @report.healthy? && @report.findings.none? { |f| f.severity == :high }
88
+ end
89
+
90
+ def failure_message
91
+ codes = @report.findings.map { |f| "#{f.code}/#{f.severity}" }.join(", ")
92
+ "expected healthy retention profile, findings: #{codes}"
93
+ end
94
+
95
+ def supports_block_expectations?
96
+ true
97
+ end
98
+ end
99
+
100
+ def retain_less_than(limit)
101
+ RetainLessThan.new(limit)
102
+ end
103
+
104
+ def grow_heap_by_more_than(bytes)
105
+ GrowHeapByMoreThan.new(bytes)
106
+ end
107
+
108
+ def heapscope_remain_healthy
109
+ RemainHealthy.new
110
+ end
111
+ end
112
+ end
113
+
114
+ if defined?(RSpec)
115
+ RSpec.configure do |config|
116
+ config.include HeapScope::RSpecMatchers
117
+ end
118
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeapScope
4
+ module Runtime
5
+ # Shared runtime adapter interface. Implementations degrade safely.
6
+ class Base
7
+ attr_reader :engine
8
+
9
+ def initialize(engine: RUBY_ENGINE)
10
+ @engine = engine
11
+ end
12
+
13
+ def allocation_tracing?
14
+ false
15
+ end
16
+
17
+ def reachable_objects?
18
+ false
19
+ end
20
+
21
+ def memsize?
22
+ false
23
+ end
24
+
25
+ def rss_tracking?
26
+ rss_bytes
27
+ true
28
+ rescue StandardError
29
+ false
30
+ end
31
+
32
+ def each_object?
33
+ false
34
+ end
35
+
36
+ def gc_stat?
37
+ defined?(GC) && GC.respond_to?(:stat)
38
+ end
39
+
40
+ def gc_stat
41
+ return {} unless gc_stat?
42
+
43
+ normalize_gc_stat(GC.stat)
44
+ end
45
+
46
+ def rss_bytes
47
+ read_rss_bytes
48
+ end
49
+
50
+ def each_object(_klass = nil)
51
+ raise CapabilityError, "ObjectSpace.each_object is unavailable on #{engine}"
52
+ end
53
+
54
+ def memsize_of(_object)
55
+ nil
56
+ end
57
+
58
+ def reachable_objects_from(_object)
59
+ []
60
+ end
61
+
62
+ def start_allocation_tracing
63
+ raise CapabilityError, "Allocation tracing unavailable on #{engine}"
64
+ end
65
+
66
+ def stop_allocation_tracing
67
+ nil
68
+ end
69
+
70
+ def allocation_info(_object)
71
+ nil
72
+ end
73
+
74
+ def process_metadata
75
+ {
76
+ pid: Process.pid,
77
+ ppid: Process.ppid,
78
+ engine: engine,
79
+ ruby: RUBY_VERSION,
80
+ platform: RUBY_PLATFORM,
81
+ thread: Thread.current.name || Thread.current.object_id.to_s
82
+ }
83
+ end
84
+
85
+ private
86
+
87
+ INTERESTING_GC_KEYS = %i[
88
+ count time major_gc_count minor_gc_count
89
+ heap_live_slots heap_free_slots heap_available_slots heap_allocatable_slots
90
+ heap_allocated_pages heap_eden_pages heap_tomb_pages heap_sorted_length
91
+ total_allocated_objects total_freed_objects
92
+ malloc_increase_bytes malloc_increase_bytes_limit
93
+ old_objects oldmalloc_increase_bytes remembered_wb_unprotected_objects
94
+ ].freeze
95
+
96
+ def normalize_gc_stat(raw)
97
+ INTERESTING_GC_KEYS.each_with_object({}) do |key, hash|
98
+ hash[key] = raw[key] if raw.key?(key)
99
+ end
100
+ end
101
+
102
+ def read_rss_bytes
103
+ if File.readable?("/proc/self/status")
104
+ line = File.foreach("/proc/self/status").find { |l| l.start_with?("VmRSS:") }
105
+ return line.split[1].to_i * 1024 if line
106
+ end
107
+
108
+ macos_rss || windows_rss
109
+ end
110
+
111
+ def macos_rss
112
+ return nil unless RUBY_PLATFORM.include?("darwin")
113
+
114
+ out = `ps -o rss= -p #{Process.pid} 2>/dev/null`.to_s.strip
115
+ return nil if out.empty?
116
+
117
+ out.to_i * 1024
118
+ end
119
+
120
+ def windows_rss
121
+ return nil unless RUBY_PLATFORM.match?(/mswin|mingw|cygwin/i)
122
+
123
+ require_relative "windows_rss"
124
+ WindowsRSS.working_set_bytes
125
+ rescue StandardError
126
+ nil
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module HeapScope
6
+ module Runtime
7
+ # Placeholder adapter — JRuby introspection differs from MRI.
8
+ class JRuby < Base
9
+ def initialize
10
+ super(engine: "jruby")
11
+ end
12
+
13
+ def each_object?
14
+ defined?(ObjectSpace) && ObjectSpace.respond_to?(:each_object)
15
+ end
16
+
17
+ def each_object(klass = nil, &)
18
+ raise CapabilityError, "ObjectSpace.each_object unavailable on JRuby" unless each_object?
19
+
20
+ if klass
21
+ ObjectSpace.each_object(klass, &)
22
+ else
23
+ ObjectSpace.each_object(&)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end