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.
- checksums.yaml +7 -0
- data/.rubocop.yml +119 -0
- data/CHANGELOG.md +86 -0
- data/CODE_OF_CONDUCT.md +38 -0
- data/CONTRIBUTING.md +72 -0
- data/Gemfile +11 -0
- data/LICENSE +21 -0
- data/README.md +342 -0
- data/Rakefile +49 -0
- data/SECURITY.md +39 -0
- data/assets/heapscope-logo.png +0 -0
- data/assets/logo.svg +24 -0
- data/assets/wordmark.svg +18 -0
- data/docs/README.md +20 -0
- data/docs/ROADMAP.md +29 -0
- data/docs/adr/0001-evidence-over-certainty.md +24 -0
- data/docs/adr/0002-local-only-privacy.md +19 -0
- data/docs/adr/0003-runtime-adapters.md +25 -0
- data/docs/adr/README.md +7 -0
- data/docs/allocators.md +13 -0
- data/docs/api/overview.md +70 -0
- data/docs/changelogs/0.1.0.md +22 -0
- data/docs/changelogs/0.2.0.md +49 -0
- data/docs/changelogs/0.3.0.md +38 -0
- data/docs/changelogs/0.4.0.md +32 -0
- data/docs/changelogs/0.5.0.md +18 -0
- data/docs/changelogs/0.6.0.md +23 -0
- data/docs/cli.md +98 -0
- data/docs/diagnostics/HS001_persistent_class_growth.md +39 -0
- data/docs/diagnostics/HS002_high_retention_ratio.md +24 -0
- data/docs/diagnostics/HS003_thread_local_retention.md +24 -0
- data/docs/diagnostics/HS004_unbounded_collection.md +19 -0
- data/docs/diagnostics/HS005_callback_accumulation.md +14 -0
- data/docs/diagnostics/HS006_closure_retention.md +15 -0
- data/docs/diagnostics/HS007_poor_gc_recovery.md +18 -0
- data/docs/diagnostics/HS008_baseline_regression.md +15 -0
- data/docs/diagnostics/HS009_high_allocation_pressure.md +13 -0
- data/docs/diagnostics/HS010_native_memory_mismatch.md +19 -0
- data/docs/guides/ci-budgets.md +43 -0
- data/docs/guides/first-retention-experiment.md +25 -0
- data/docs/guides/production-safe.md +17 -0
- data/docs/index.md +38 -0
- data/examples/cache_vs_leak.rb +30 -0
- data/examples/closure_capture.rb +20 -0
- data/examples/healthy_churn.rb +16 -0
- data/examples/heapscope.yml +12 -0
- data/examples/import_leak.rb +19 -0
- data/examples/probe_and_session.rb +22 -0
- data/examples/thread_local_leak.rb +31 -0
- data/exe/heapscope +7 -0
- data/heapscope.gemspec +73 -0
- data/lib/heapscope/aging.rb +114 -0
- data/lib/heapscope/analyzer.rb +333 -0
- data/lib/heapscope/baseline.rb +77 -0
- data/lib/heapscope/branding.rb +98 -0
- data/lib/heapscope/budget.rb +108 -0
- data/lib/heapscope/capabilities.rb +42 -0
- data/lib/heapscope/catalog.rb +71 -0
- data/lib/heapscope/cli/color.rb +67 -0
- data/lib/heapscope/cli/commands/capture.rb +289 -0
- data/lib/heapscope/cli/commands/diffing.rb +157 -0
- data/lib/heapscope/cli/commands/meta.rb +244 -0
- data/lib/heapscope/cli/commands/reporting.rb +228 -0
- data/lib/heapscope/cli/completion.rb +72 -0
- data/lib/heapscope/cli/help.rb +226 -0
- data/lib/heapscope/cli/support.rb +126 -0
- data/lib/heapscope/cli.rb +165 -0
- data/lib/heapscope/closures.rb +102 -0
- data/lib/heapscope/collector.rb +167 -0
- data/lib/heapscope/config.rb +196 -0
- data/lib/heapscope/detectors.rb +131 -0
- data/lib/heapscope/diff.rb +130 -0
- data/lib/heapscope/dominators.rb +87 -0
- data/lib/heapscope/errors.rb +13 -0
- data/lib/heapscope/extrapolation.rb +51 -0
- data/lib/heapscope/findings.rb +149 -0
- data/lib/heapscope/globals.rb +108 -0
- data/lib/heapscope/graph.rb +218 -0
- data/lib/heapscope/growth.rb +108 -0
- data/lib/heapscope/middleware.rb +35 -0
- data/lib/heapscope/minitest.rb +47 -0
- data/lib/heapscope/monitor.rb +160 -0
- data/lib/heapscope/noise.rb +48 -0
- data/lib/heapscope/notifications.rb +79 -0
- data/lib/heapscope/pack.rb +55 -0
- data/lib/heapscope/paths.rb +64 -0
- data/lib/heapscope/rails.rb +71 -0
- data/lib/heapscope/report/html.rb +283 -0
- data/lib/heapscope/report/markdown.rb +78 -0
- data/lib/heapscope/report/text.rb +204 -0
- data/lib/heapscope/report.rb +308 -0
- data/lib/heapscope/retention.rb +91 -0
- data/lib/heapscope/rspec.rb +118 -0
- data/lib/heapscope/runtime/base.rb +130 -0
- data/lib/heapscope/runtime/jruby.rb +28 -0
- data/lib/heapscope/runtime/mri.rb +86 -0
- data/lib/heapscope/runtime/truffleruby.rb +40 -0
- data/lib/heapscope/runtime/windows_rss.rb +75 -0
- data/lib/heapscope/runtime.rb +33 -0
- data/lib/heapscope/schema.rb +28 -0
- data/lib/heapscope/scorecard.rb +85 -0
- data/lib/heapscope/session.rb +90 -0
- data/lib/heapscope/sidekiq_middleware.rb +36 -0
- data/lib/heapscope/snapshot.rb +165 -0
- data/lib/heapscope/suggest.rb +99 -0
- data/lib/heapscope/tables.rb +58 -0
- data/lib/heapscope/trend_store.rb +41 -0
- data/lib/heapscope/version.rb +6 -0
- data/lib/heapscope.rb +307 -0
- 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
|