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,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# Ignore-pattern suggestions and prioritized next-step recommendations.
|
|
5
|
+
# Never auto-applies configuration — returns guidance only.
|
|
6
|
+
module Suggest
|
|
7
|
+
NEXT_STEP_BY_CODE = {
|
|
8
|
+
"HS001" => "Run HeapScope.retention_test (multi-cycle + force_gc) on the top growing class.",
|
|
9
|
+
"HS002" => "Force GC between snapshots and re-measure retention ratio after idle recovery.",
|
|
10
|
+
"HS003" => "Inventory Thread.current keys after the request/job and clear request-scoped locals.",
|
|
11
|
+
"HS004" => "Bound or expire the growing collection; confirm whether it is an intentional cache.",
|
|
12
|
+
"HS005" => "Audit callback/subscriber registries for unbounded appends across requests.",
|
|
13
|
+
"HS006" => "Check Proc/lambda captures for long-lived owners (controllers, jobs, threads).",
|
|
14
|
+
"HS007" => "Compare pre-GC vs post-GC snapshots; investigate sticky populations that survive GC.",
|
|
15
|
+
"HS008" => "Diff against the saved baseline and gate CI with Budget.preset(:ci_strict).",
|
|
16
|
+
"HS009" => "Treat as churn unless RSS/live slots also climb; optimize hot allocation sites if GC-bound.",
|
|
17
|
+
"HS010" => "Do not assume a Ruby object leak — check native extensions and allocator fragmentation."
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def ignore_patterns(report)
|
|
23
|
+
names = report.diff&.growing_classes(50)&.map { |c| c[:name] } || []
|
|
24
|
+
patterns = Noise.suggestion_patterns
|
|
25
|
+
hints = names.select { |n| patterns.any? { |re| n.match?(re) } }
|
|
26
|
+
stable = Array(report.suspects).select { |s| s[:severity] == :low }.map { |s| s[:name] }
|
|
27
|
+
(hints + stable).uniq
|
|
28
|
+
.reject { |n| Noise.noisy?(n) }
|
|
29
|
+
.map { |n| "^#{Regexp.escape(n)}" }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def next_steps(report, limit: 8)
|
|
33
|
+
steps = []
|
|
34
|
+
ranked = Findings.rank_and_dedupe(report.findings)
|
|
35
|
+
ranked.first(limit).each do |finding|
|
|
36
|
+
base = NEXT_STEP_BY_CODE[finding.code] || "Review finding #{finding.code} evidence and suggestions."
|
|
37
|
+
subject = finding.subject ? " (#{finding.subject})" : ""
|
|
38
|
+
steps << {
|
|
39
|
+
priority: Findings.priority_score(finding),
|
|
40
|
+
code: finding.code,
|
|
41
|
+
severity: finding.severity,
|
|
42
|
+
action: "#{base}#{subject}",
|
|
43
|
+
suggestions: finding.suggestions
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if report.summary[:healthy] == false && steps.empty?
|
|
48
|
+
steps << {
|
|
49
|
+
priority: 40,
|
|
50
|
+
code: nil,
|
|
51
|
+
severity: :medium,
|
|
52
|
+
action: "Review top suspects and class growth table; run heapscope suggest on this report.",
|
|
53
|
+
suggestions: []
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
ignores = ignore_patterns(report)
|
|
58
|
+
if ignores.any?
|
|
59
|
+
steps << {
|
|
60
|
+
priority: 5,
|
|
61
|
+
code: nil,
|
|
62
|
+
severity: :low,
|
|
63
|
+
action: "Review #{ignores.size} suggested ignore_pattern(s) before applying (never auto-applied).",
|
|
64
|
+
suggestions: ignores.first(5)
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
steps.sort_by { |s| -s[:priority].to_i }.first(limit)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def report_text(report)
|
|
72
|
+
patterns = ignore_patterns(report)
|
|
73
|
+
steps = next_steps(report)
|
|
74
|
+
lines = []
|
|
75
|
+
|
|
76
|
+
lines << "Next steps (prioritized):"
|
|
77
|
+
if steps.empty?
|
|
78
|
+
lines << " (none — report looks quiet)"
|
|
79
|
+
else
|
|
80
|
+
steps.each_with_index do |step, i|
|
|
81
|
+
tag = step[:code] ? "[#{step[:code]}]" : "[hint]"
|
|
82
|
+
lines << " #{i + 1}. #{tag} #{step[:action]}"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
lines << ""
|
|
87
|
+
if patterns.empty?
|
|
88
|
+
lines << "No ignore suggestions."
|
|
89
|
+
else
|
|
90
|
+
lines << "Suggested ignore_patterns (review before applying):"
|
|
91
|
+
patterns.each { |p| lines << " - #{p}" }
|
|
92
|
+
lines << ""
|
|
93
|
+
lines << "HeapScope.configure { |c| c.ignore_patterns << /.../ }"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
lines.join("\n")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# ASCII / Markdown tables for diffs and class growth.
|
|
5
|
+
module Tables
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def class_growth(diff, limit: 15, format: :ascii)
|
|
9
|
+
rows = diff.growing_classes(limit).map do |c|
|
|
10
|
+
[
|
|
11
|
+
c[:name].to_s,
|
|
12
|
+
c[:before_count].to_s,
|
|
13
|
+
c[:after_count].to_s,
|
|
14
|
+
signed(c[:delta_count]),
|
|
15
|
+
bytes(c[:delta_bytes])
|
|
16
|
+
]
|
|
17
|
+
end
|
|
18
|
+
headers = %w[CLASS BEFORE AFTER DELTA BYTES]
|
|
19
|
+
format == :markdown ? markdown(headers, rows) : ascii(headers, rows)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def findings(report, format: :ascii)
|
|
23
|
+
rows = report.findings.map { |f| [f.code, f.severity.to_s.upcase, f.title.to_s, f.subject.to_s] }
|
|
24
|
+
headers = %w[CODE SEV TITLE SUBJECT]
|
|
25
|
+
format == :markdown ? markdown(headers, rows) : ascii(headers, rows)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ascii(headers, rows)
|
|
29
|
+
widths = headers.each_index.map do |i|
|
|
30
|
+
([headers[i].length] + rows.map { |r| r[i].to_s.length }).max
|
|
31
|
+
end
|
|
32
|
+
sep = "+#{widths.map { |w| '-' * (w + 2) }.join('+')}+"
|
|
33
|
+
line = lambda do |cols|
|
|
34
|
+
cells = cols.each_with_index.map { |c, i| c.to_s.ljust(widths[i]) }.join(" | ")
|
|
35
|
+
"| #{cells} |"
|
|
36
|
+
end
|
|
37
|
+
([sep, line.call(headers), sep] + rows.map { |r| line.call(r) } + [sep]).join("\n")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def markdown(headers, rows)
|
|
41
|
+
lines = []
|
|
42
|
+
lines << "| #{headers.join(' | ')} |"
|
|
43
|
+
lines << "| #{headers.map { '---' }.join(' | ')} |"
|
|
44
|
+
rows.each { |r| lines << "| #{r.join(' | ')} |" }
|
|
45
|
+
lines.join("\n")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def signed(n)
|
|
49
|
+
n.positive? ? "+#{n}" : n.to_s
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def bytes(n)
|
|
53
|
+
return "n/a" if n.nil?
|
|
54
|
+
|
|
55
|
+
format("%+.2fMB", n.to_f / (1024 * 1024))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module HeapScope
|
|
6
|
+
# Persists monitor samples for historical trend reports.
|
|
7
|
+
class TrendStore
|
|
8
|
+
def initialize(path)
|
|
9
|
+
@path = path
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def append(sample_hash)
|
|
13
|
+
rows = load
|
|
14
|
+
rows << sample_hash
|
|
15
|
+
File.write(@path, JSON.pretty_generate(rows))
|
|
16
|
+
rows.size
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def load
|
|
20
|
+
return [] unless File.exist?(@path)
|
|
21
|
+
|
|
22
|
+
JSON.parse(File.read(@path), symbolize_names: true)
|
|
23
|
+
rescue StandardError
|
|
24
|
+
[]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def summary
|
|
28
|
+
rows = load
|
|
29
|
+
return { samples: 0 } if rows.empty?
|
|
30
|
+
|
|
31
|
+
rss = rows.map { |r| r[:rss_bytes].to_i }
|
|
32
|
+
live = rows.map { |r| r[:live_slots].to_i }
|
|
33
|
+
{
|
|
34
|
+
samples: rows.size,
|
|
35
|
+
rss: { first: rss.first, last: rss.last, delta: rss.last - rss.first, max: rss.max },
|
|
36
|
+
live_slots: { first: live.first, last: live.last, delta: live.last - live.first, max: live.max },
|
|
37
|
+
top_classes: rows.map { |r| r[:top_class] }.compact.tally.sort_by { |_, v| -v }.first(5)
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/heapscope.rb
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "heapscope/version"
|
|
4
|
+
require_relative "heapscope/errors"
|
|
5
|
+
require_relative "heapscope/config"
|
|
6
|
+
require_relative "heapscope/capabilities"
|
|
7
|
+
require_relative "heapscope/runtime"
|
|
8
|
+
require_relative "heapscope/snapshot"
|
|
9
|
+
require_relative "heapscope/collector"
|
|
10
|
+
require_relative "heapscope/diff"
|
|
11
|
+
require_relative "heapscope/growth"
|
|
12
|
+
require_relative "heapscope/findings"
|
|
13
|
+
require_relative "heapscope/analyzer"
|
|
14
|
+
require_relative "heapscope/retention"
|
|
15
|
+
require_relative "heapscope/graph"
|
|
16
|
+
require_relative "heapscope/report"
|
|
17
|
+
require_relative "heapscope/budget"
|
|
18
|
+
require_relative "heapscope/baseline"
|
|
19
|
+
require_relative "heapscope/monitor"
|
|
20
|
+
require_relative "heapscope/detectors"
|
|
21
|
+
require_relative "heapscope/extrapolation"
|
|
22
|
+
require_relative "heapscope/aging"
|
|
23
|
+
require_relative "heapscope/globals"
|
|
24
|
+
require_relative "heapscope/closures"
|
|
25
|
+
require_relative "heapscope/dominators"
|
|
26
|
+
require_relative "heapscope/noise"
|
|
27
|
+
require_relative "heapscope/notifications"
|
|
28
|
+
require_relative "heapscope/paths"
|
|
29
|
+
require_relative "heapscope/trend_store"
|
|
30
|
+
require_relative "heapscope/scorecard"
|
|
31
|
+
require_relative "heapscope/session"
|
|
32
|
+
require_relative "heapscope/tables"
|
|
33
|
+
require_relative "heapscope/schema"
|
|
34
|
+
require_relative "heapscope/branding"
|
|
35
|
+
require_relative "heapscope/suggest"
|
|
36
|
+
require_relative "heapscope/catalog"
|
|
37
|
+
require_relative "heapscope/pack"
|
|
38
|
+
|
|
39
|
+
# HeapScope — Ruby object retention, heap growth, and memory leak diagnostics.
|
|
40
|
+
#
|
|
41
|
+
# Distinguishes allocation pressure from retention, and intentional retention
|
|
42
|
+
# from suspicious patterns. Prefers evidence over certainty.
|
|
43
|
+
module HeapScope
|
|
44
|
+
class << self
|
|
45
|
+
def snapshot(mode: nil, **opts)
|
|
46
|
+
Collector.new.capture(mode: mode, **opts)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def compare(before, after, metadata: {})
|
|
50
|
+
before = Snapshot.load(before) if before.is_a?(String)
|
|
51
|
+
after = Snapshot.load(after) if after.is_a?(String)
|
|
52
|
+
diff = Diff.new(before, after)
|
|
53
|
+
analysis = Analyzer.new.analyze_diff(diff, context: metadata)
|
|
54
|
+
Report.from_diff(diff, analysis: analysis, metadata: metadata)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def measure(force_gc: config.force_gc_default, mode: nil, recovery_wait: nil,
|
|
58
|
+
track_allocations: config.track_allocations, metadata: {})
|
|
59
|
+
raise ArgumentError, "block required" unless block_given?
|
|
60
|
+
|
|
61
|
+
tracer = AllocationTracer.new
|
|
62
|
+
tracing = track_allocations && tracer.available?
|
|
63
|
+
tracer.start! if tracing
|
|
64
|
+
|
|
65
|
+
GC.start if force_gc
|
|
66
|
+
before = snapshot(mode: mode || :standard, metadata: metadata.merge(phase: "before"))
|
|
67
|
+
|
|
68
|
+
allocated_before = Runtime.current.gc_stat[:total_allocated_objects]
|
|
69
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
70
|
+
result = yield
|
|
71
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
72
|
+
allocated_after = Runtime.current.gc_stat[:total_allocated_objects]
|
|
73
|
+
|
|
74
|
+
immediately_after = snapshot(mode: mode || :standard, metadata: metadata.merge(phase: "immediately_after"))
|
|
75
|
+
|
|
76
|
+
GC.start if force_gc
|
|
77
|
+
after_gc = snapshot(mode: mode || :standard, metadata: metadata.merge(phase: "after_gc"))
|
|
78
|
+
|
|
79
|
+
after_idle = nil
|
|
80
|
+
if recovery_wait&.positive?
|
|
81
|
+
sleep recovery_wait
|
|
82
|
+
GC.start if force_gc
|
|
83
|
+
after_idle = snapshot(mode: mode || :lightweight, metadata: metadata.merge(phase: "after_idle"))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
final = after_idle || after_gc
|
|
87
|
+
diff = Diff.new(before, final)
|
|
88
|
+
analysis = Analyzer.new.analyze_diff(
|
|
89
|
+
diff,
|
|
90
|
+
context: metadata.merge(force_gc: force_gc)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
allocated = (allocated_after || 0) - (allocated_before || 0)
|
|
94
|
+
rate = elapsed.positive? ? (allocated / elapsed) : nil
|
|
95
|
+
|
|
96
|
+
report = Report.from_diff(
|
|
97
|
+
diff,
|
|
98
|
+
analysis: analysis,
|
|
99
|
+
metadata: metadata.merge(
|
|
100
|
+
kind: "measure",
|
|
101
|
+
force_gc: force_gc,
|
|
102
|
+
elapsed_seconds: elapsed.round(4),
|
|
103
|
+
objects_allocated_during: allocated,
|
|
104
|
+
allocation_rate_per_sec: rate&.round(1),
|
|
105
|
+
phases: {
|
|
106
|
+
before: before.id,
|
|
107
|
+
immediately_after: immediately_after.id,
|
|
108
|
+
after_gc: after_gc.id,
|
|
109
|
+
after_idle: after_idle&.id
|
|
110
|
+
}.compact,
|
|
111
|
+
recovery: recovery_stats(before, immediately_after, after_gc, after_idle),
|
|
112
|
+
block_result_class: result.class.name
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
report
|
|
116
|
+
ensure
|
|
117
|
+
tracer&.stop! if tracing
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def retention_test(cycles: 5, force_gc: true, mode: :lightweight, metadata: {})
|
|
121
|
+
raise ArgumentError, "block required" unless block_given?
|
|
122
|
+
|
|
123
|
+
session = RetentionSession.new(force_gc: force_gc, mode: mode, metadata: metadata)
|
|
124
|
+
session.sample(label: "baseline")
|
|
125
|
+
cycles.times do |i|
|
|
126
|
+
yield
|
|
127
|
+
session.sample(label: "cycle_#{i + 1}")
|
|
128
|
+
end
|
|
129
|
+
report = session.finish
|
|
130
|
+
# Enrich metadata
|
|
131
|
+
report.metadata[:kind] = "retention_test"
|
|
132
|
+
report.metadata[:cycles] = cycles
|
|
133
|
+
report
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def experiment(runs: 10, force_gc: true, mode: :lightweight, metadata: {}, &block)
|
|
137
|
+
raise ArgumentError, "block required" unless block_given?
|
|
138
|
+
|
|
139
|
+
results = runs.times.map do |i|
|
|
140
|
+
measure(force_gc: force_gc, mode: mode, metadata: metadata.merge(run: i), &block)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
retained = results.map { |r| r.diff&.surviving_estimate.to_i }
|
|
144
|
+
rss = results.map { |r| r.diff&.rss_delta.to_i }
|
|
145
|
+
stats = lambda do |arr|
|
|
146
|
+
sorted = arr.sort
|
|
147
|
+
{
|
|
148
|
+
min: sorted.first,
|
|
149
|
+
max: sorted.last,
|
|
150
|
+
median: percentile(sorted, 50),
|
|
151
|
+
p95: percentile(sorted, 95),
|
|
152
|
+
mean: (arr.sum.to_f / arr.size).round(2),
|
|
153
|
+
variance: Growth.sample_variance(arr.map(&:to_f)).round(2)
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
Report.new(
|
|
158
|
+
runtime_info: { ruby: RUBY_VERSION, engine: RUBY_ENGINE },
|
|
159
|
+
summary: {
|
|
160
|
+
healthy: results.all?(&:healthy?),
|
|
161
|
+
runs: runs,
|
|
162
|
+
retained_objects: stats.call(retained),
|
|
163
|
+
rss_delta: stats.call(rss)
|
|
164
|
+
},
|
|
165
|
+
findings: results.flat_map(&:findings).uniq { |f| [f.code, f.subject] },
|
|
166
|
+
suspects: results.flat_map(&:suspects).group_by { |s| s[:name] }.map do |_name, list|
|
|
167
|
+
list.max_by { |s| s[:delta_count] }.merge(runs_seen: list.size)
|
|
168
|
+
end,
|
|
169
|
+
metadata: metadata.merge(kind: "experiment", runs: runs),
|
|
170
|
+
before: results.first&.before,
|
|
171
|
+
after: results.last&.after,
|
|
172
|
+
diff: results.last&.diff,
|
|
173
|
+
classes: results.last&.classes || []
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def repeat(times, force_gc: true, mode: :lightweight, &block)
|
|
178
|
+
retention_test(cycles: times, force_gc: force_gc, mode: mode, &block)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Soft check — always returns a report with budget_result.
|
|
182
|
+
def check_budget(budget:, force_gc: true, mode: :standard, metadata: {}, &block)
|
|
183
|
+
raise ArgumentError, "block required" unless block
|
|
184
|
+
|
|
185
|
+
report = measure(force_gc: force_gc, mode: mode, metadata: metadata, &block)
|
|
186
|
+
result = budget.evaluate(report)
|
|
187
|
+
Report.new(
|
|
188
|
+
schema_version: report.schema_version,
|
|
189
|
+
heapscope_version: report.heapscope_version,
|
|
190
|
+
runtime_info: report.runtime_info,
|
|
191
|
+
summary: report.summary.merge(budget_passed: result[:passed]),
|
|
192
|
+
before: report.before,
|
|
193
|
+
after: report.after,
|
|
194
|
+
diff: report.diff,
|
|
195
|
+
findings: report.findings,
|
|
196
|
+
suspects: report.suspects,
|
|
197
|
+
classes: report.classes,
|
|
198
|
+
allocation_sites: report.allocation_sites,
|
|
199
|
+
retention: report.retention,
|
|
200
|
+
metadata: report.metadata,
|
|
201
|
+
limitations: report.limitations,
|
|
202
|
+
budget_result: result,
|
|
203
|
+
reproduction: report.reproduction
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Like check_budget, but raises BudgetExceededError on failure.
|
|
208
|
+
def check(budget:, **opts, &block)
|
|
209
|
+
report = check_budget(budget: budget, **opts, &block)
|
|
210
|
+
return report if report.passed_budget?
|
|
211
|
+
|
|
212
|
+
raise BudgetExceededError, report.budget_result[:violations].join("; ")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def capabilities
|
|
216
|
+
Capabilities.new(Runtime.current)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def runtime
|
|
220
|
+
Runtime.current
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def doctor
|
|
224
|
+
{
|
|
225
|
+
version: VERSION,
|
|
226
|
+
ruby: RUBY_VERSION,
|
|
227
|
+
engine: RUBY_ENGINE,
|
|
228
|
+
platform: RUBY_PLATFORM,
|
|
229
|
+
capabilities: capabilities.to_h,
|
|
230
|
+
config: {
|
|
231
|
+
mode: config.mode,
|
|
232
|
+
ignore_patterns: config.ignore_patterns.map(&:inspect)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def overhead(mode: :lightweight, runs: 3)
|
|
238
|
+
Overhead.measure_snapshot(mode: mode, runs: runs)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def probe(title: "probe", **opts, &block)
|
|
242
|
+
Probe.run(title: title, **opts, &block)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def session(name, root: Dir.pwd)
|
|
246
|
+
Session.open(name, root: root)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def scorecard(report, title: "HeapScope")
|
|
250
|
+
Scorecard.from_report(report, title: title)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def codes
|
|
254
|
+
Catalog.codes
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def about
|
|
258
|
+
Branding.about_text
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def branding
|
|
262
|
+
Branding.to_h
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def suggest_ignores(report)
|
|
266
|
+
Suggest.ignore_patterns(report)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def next_steps(report, limit: 8)
|
|
270
|
+
Suggest.next_steps(report, limit: limit)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def budget_preset(name)
|
|
274
|
+
Budget.preset(name)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def pack(report, dir, label: "heapscope-report")
|
|
278
|
+
Pack.export(report, dir, label: label)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def write_config!(path = "heapscope.yml", force: false)
|
|
282
|
+
ConfigLoader.write_starter!(path, force: force)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
private
|
|
286
|
+
|
|
287
|
+
def recovery_stats(before, immediate, after_gc, after_idle)
|
|
288
|
+
{
|
|
289
|
+
baseline_rss: before.rss_bytes,
|
|
290
|
+
peak_rss: immediate.rss_bytes,
|
|
291
|
+
after_gc_rss: after_gc.rss_bytes,
|
|
292
|
+
after_idle_rss: after_idle&.rss_bytes,
|
|
293
|
+
baseline_live: before.heap_live_slots,
|
|
294
|
+
peak_live: immediate.heap_live_slots,
|
|
295
|
+
after_gc_live: after_gc.heap_live_slots,
|
|
296
|
+
after_idle_live: after_idle&.heap_live_slots
|
|
297
|
+
}
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def percentile(sorted, pct)
|
|
301
|
+
return nil if sorted.empty?
|
|
302
|
+
|
|
303
|
+
k = ((pct / 100.0) * (sorted.size - 1)).round
|
|
304
|
+
sorted[k]
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|