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,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# Approximate dominator-style analysis via bounded retained-size estimates.
|
|
5
|
+
# Explicitly labeled approximate — not a JVM-style precise dominator tree.
|
|
6
|
+
class Dominators
|
|
7
|
+
Candidate = Struct.new(:class_name, :shallow, :approx_retained, :objects, :note, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
def initialize(runtime: Runtime.current, config: HeapScope.config)
|
|
10
|
+
@runtime = runtime
|
|
11
|
+
@config = config
|
|
12
|
+
@graph = Graph.new(runtime: runtime, config: config)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def top_retainers(sample_objects, limit: 10)
|
|
16
|
+
sample_objects.filter_map do |obj|
|
|
17
|
+
next if obj.nil?
|
|
18
|
+
|
|
19
|
+
est = @graph.estimate_retained_size(obj, max_objects: 2_000, max_depth: @config.max_graph_depth)
|
|
20
|
+
Candidate.new(
|
|
21
|
+
class_name: begin
|
|
22
|
+
obj.class.name
|
|
23
|
+
rescue StandardError
|
|
24
|
+
"unknown"
|
|
25
|
+
end,
|
|
26
|
+
shallow: est[:shallow],
|
|
27
|
+
approx_retained: est[:retained],
|
|
28
|
+
objects: est[:objects],
|
|
29
|
+
note: "Approximate retained size via bounded traversal — not a precise dominator."
|
|
30
|
+
)
|
|
31
|
+
rescue StandardError
|
|
32
|
+
nil
|
|
33
|
+
end.sort_by { |c| -(c.approx_retained || 0) }.first(limit)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def from_thread_locals(limit: 5)
|
|
37
|
+
roots = []
|
|
38
|
+
Thread.list.each do |thread|
|
|
39
|
+
next unless thread.respond_to?(:keys)
|
|
40
|
+
|
|
41
|
+
thread.each_key do |key|
|
|
42
|
+
roots << thread[key]
|
|
43
|
+
rescue StandardError
|
|
44
|
+
next
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
top_retainers(roots.compact.first(20), limit: limit)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Heuristic fragmentation / native mismatch indicators.
|
|
52
|
+
module Fragmentation
|
|
53
|
+
module_function
|
|
54
|
+
|
|
55
|
+
def assess(snapshot)
|
|
56
|
+
gc = snapshot.gc_stat
|
|
57
|
+
rss = snapshot.rss_bytes
|
|
58
|
+
live = gc[:heap_live_slots]
|
|
59
|
+
free = gc[:heap_free_slots]
|
|
60
|
+
pages = gc[:heap_allocated_pages]
|
|
61
|
+
return { status: :unknown, note: "Insufficient GC/RSS data." } if rss.nil? || live.nil?
|
|
62
|
+
|
|
63
|
+
heap_bytes_estimate = live.to_i * 40 # very rough average slot estimate
|
|
64
|
+
ratio = heap_bytes_estimate.positive? ? rss.to_f / heap_bytes_estimate : nil
|
|
65
|
+
free_ratio = (live.to_i + free.to_i).positive? ? free.to_f / (live + free) : nil
|
|
66
|
+
|
|
67
|
+
status =
|
|
68
|
+
if ratio && ratio > 4.0 && pages.to_i > 100
|
|
69
|
+
:potential_fragmentation
|
|
70
|
+
elsif ratio && ratio > 2.5
|
|
71
|
+
:elevated_rss_vs_heap
|
|
72
|
+
else
|
|
73
|
+
:normal
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
status: status,
|
|
78
|
+
rss_bytes: rss,
|
|
79
|
+
heap_bytes_estimate: heap_bytes_estimate,
|
|
80
|
+
rss_to_heap_ratio: ratio&.round(2),
|
|
81
|
+
free_slot_ratio: free_ratio&.round(3),
|
|
82
|
+
heap_allocated_pages: pages,
|
|
83
|
+
note: "Potential allocator/heap fragmentation indicator — not confirmed fragmentation."
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class UnsupportedRuntimeError < Error; end
|
|
7
|
+
class SnapshotError < Error; end
|
|
8
|
+
class InvalidReportError < Error; end
|
|
9
|
+
class CapabilityError < Error; end
|
|
10
|
+
class AnalysisLimitError < Error; end
|
|
11
|
+
class ConfigurationError < Error; end
|
|
12
|
+
class BudgetExceededError < Error; end
|
|
13
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# Extrapolates retention rate into a labeled estimate. Never presented as guaranteed.
|
|
5
|
+
module Extrapolation
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def retention_per_hour(retained_bytes:, elapsed_seconds:)
|
|
9
|
+
return nil if elapsed_seconds.nil? || elapsed_seconds <= 0 || retained_bytes.nil?
|
|
10
|
+
|
|
11
|
+
rate = retained_bytes.to_f / elapsed_seconds
|
|
12
|
+
{
|
|
13
|
+
bytes_per_second: rate.round(2),
|
|
14
|
+
bytes_per_hour: (rate * 3600).round,
|
|
15
|
+
label: "extrapolation",
|
|
16
|
+
caveat: "At the current observed retention rate — not a prediction of future usage."
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def objects_per_run(retained_objects:, runs:)
|
|
21
|
+
return nil if runs.nil? || runs <= 0
|
|
22
|
+
|
|
23
|
+
{
|
|
24
|
+
retained_objects_per_run: (retained_objects.to_f / runs).round(2),
|
|
25
|
+
label: "per-run retention"
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Classifies churn vs sticky populations for reports.
|
|
31
|
+
module Classification
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def churn_vs_leak(allocated:, retained:)
|
|
35
|
+
allocated = allocated.to_i
|
|
36
|
+
retained = retained.to_i
|
|
37
|
+
return :insufficient_data if allocated <= 0
|
|
38
|
+
|
|
39
|
+
ratio = retained.to_f / allocated
|
|
40
|
+
if allocated > 10_000 && ratio < 0.05
|
|
41
|
+
:high_churn_low_retention
|
|
42
|
+
elsif allocated < 1_000 && ratio > 0.5
|
|
43
|
+
:sticky
|
|
44
|
+
elsif allocated > 10_000 && ratio > 0.3
|
|
45
|
+
:severe
|
|
46
|
+
else
|
|
47
|
+
:normal
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# Stable diagnostic codes. Each finding separates facts, derived behavior,
|
|
5
|
+
# hypothesis, and suspected cause — never collapsed into fake certainty.
|
|
6
|
+
module Diagnostics
|
|
7
|
+
CODES = {
|
|
8
|
+
"HS001" => {
|
|
9
|
+
id: "HS001",
|
|
10
|
+
name: "persistent_class_growth",
|
|
11
|
+
title: "Persistent object population growth",
|
|
12
|
+
default_severity: :medium
|
|
13
|
+
},
|
|
14
|
+
"HS002" => {
|
|
15
|
+
id: "HS002",
|
|
16
|
+
name: "high_retention_ratio",
|
|
17
|
+
title: "High retention ratio after workload",
|
|
18
|
+
default_severity: :high
|
|
19
|
+
},
|
|
20
|
+
"HS003" => {
|
|
21
|
+
id: "HS003",
|
|
22
|
+
name: "thread_local_retention",
|
|
23
|
+
title: "Thread-local retention pattern",
|
|
24
|
+
default_severity: :high
|
|
25
|
+
},
|
|
26
|
+
"HS004" => {
|
|
27
|
+
id: "HS004",
|
|
28
|
+
name: "unbounded_collection",
|
|
29
|
+
title: "Unbounded collection growth candidate",
|
|
30
|
+
default_severity: :high
|
|
31
|
+
},
|
|
32
|
+
"HS005" => {
|
|
33
|
+
id: "HS005",
|
|
34
|
+
name: "callback_accumulation",
|
|
35
|
+
title: "Callback or subscriber accumulation",
|
|
36
|
+
default_severity: :medium
|
|
37
|
+
},
|
|
38
|
+
"HS006" => {
|
|
39
|
+
id: "HS006",
|
|
40
|
+
name: "closure_retention",
|
|
41
|
+
title: "Proc/closure retention candidate",
|
|
42
|
+
default_severity: :medium
|
|
43
|
+
},
|
|
44
|
+
"HS007" => {
|
|
45
|
+
id: "HS007",
|
|
46
|
+
name: "poor_gc_recovery",
|
|
47
|
+
title: "Poor recovery after forced GC",
|
|
48
|
+
default_severity: :medium
|
|
49
|
+
},
|
|
50
|
+
"HS008" => {
|
|
51
|
+
id: "HS008",
|
|
52
|
+
name: "baseline_regression",
|
|
53
|
+
title: "Memory retention regression vs baseline",
|
|
54
|
+
default_severity: :high
|
|
55
|
+
},
|
|
56
|
+
"HS009" => {
|
|
57
|
+
id: "HS009",
|
|
58
|
+
name: "high_allocation_pressure",
|
|
59
|
+
title: "High allocation pressure (churn)",
|
|
60
|
+
default_severity: :low
|
|
61
|
+
},
|
|
62
|
+
"HS010" => {
|
|
63
|
+
id: "HS010",
|
|
64
|
+
name: "native_memory_mismatch",
|
|
65
|
+
title: "RSS growth without matching Ruby heap growth",
|
|
66
|
+
default_severity: :medium
|
|
67
|
+
}
|
|
68
|
+
}.freeze
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Ranking / dedupe helpers for findings lists.
|
|
72
|
+
module Findings
|
|
73
|
+
SEVERITY_WEIGHT = { high: 100, medium: 50, low: 10 }.freeze
|
|
74
|
+
CODE_PRIORITY = {
|
|
75
|
+
"HS002" => 20,
|
|
76
|
+
"HS003" => 18,
|
|
77
|
+
"HS004" => 18,
|
|
78
|
+
"HS008" => 17,
|
|
79
|
+
"HS001" => 15,
|
|
80
|
+
"HS007" => 14,
|
|
81
|
+
"HS005" => 12,
|
|
82
|
+
"HS006" => 12,
|
|
83
|
+
"HS010" => 10,
|
|
84
|
+
"HS009" => 4
|
|
85
|
+
}.freeze
|
|
86
|
+
|
|
87
|
+
module_function
|
|
88
|
+
|
|
89
|
+
def priority_score(finding)
|
|
90
|
+
sev = SEVERITY_WEIGHT[finding.severity] || 0
|
|
91
|
+
code = CODE_PRIORITY[finding.code] || 1
|
|
92
|
+
sev + code
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def rank_and_dedupe(findings)
|
|
96
|
+
grouped = Array(findings).group_by { |f| [f.code, f.subject.to_s] }
|
|
97
|
+
deduped = grouped.map do |_key, list|
|
|
98
|
+
list.max_by { |f| priority_score(f) }
|
|
99
|
+
end
|
|
100
|
+
deduped.sort_by { |f| [-priority_score(f), f.code.to_s, f.subject.to_s] }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
class Finding
|
|
105
|
+
attr_reader :code, :severity, :title, :facts, :derived, :hypothesis,
|
|
106
|
+
:suspected_cause, :evidence, :suggestions, :subject
|
|
107
|
+
|
|
108
|
+
def initialize(code:, severity:, title: nil, facts: [], derived: [],
|
|
109
|
+
hypothesis: nil, suspected_cause: nil, evidence: [],
|
|
110
|
+
suggestions: [], subject: nil)
|
|
111
|
+
meta = Diagnostics::CODES[code] || {}
|
|
112
|
+
@code = code
|
|
113
|
+
@severity = severity || meta[:default_severity] || :low
|
|
114
|
+
@title = title || meta[:title] || code
|
|
115
|
+
@facts = Array(facts)
|
|
116
|
+
@derived = Array(derived)
|
|
117
|
+
@hypothesis = hypothesis
|
|
118
|
+
@suspected_cause = suspected_cause
|
|
119
|
+
@evidence = Array(evidence)
|
|
120
|
+
@suggestions = Array(suggestions)
|
|
121
|
+
@subject = subject
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def name
|
|
125
|
+
Diagnostics::CODES.dig(code, :name)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def priority
|
|
129
|
+
Findings.priority_score(self)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def to_h
|
|
133
|
+
{
|
|
134
|
+
code: code,
|
|
135
|
+
name: name,
|
|
136
|
+
severity: severity.to_s,
|
|
137
|
+
title: title,
|
|
138
|
+
subject: subject,
|
|
139
|
+
priority: priority,
|
|
140
|
+
facts: facts,
|
|
141
|
+
derived: derived,
|
|
142
|
+
hypothesis: hypothesis,
|
|
143
|
+
suspected_cause: suspected_cause,
|
|
144
|
+
evidence: evidence,
|
|
145
|
+
suggestions: suggestions
|
|
146
|
+
}.compact
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module HeapScope
|
|
6
|
+
# Global / constant / class-variable retention candidates.
|
|
7
|
+
module Globals
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def inventory(max_entries: 200)
|
|
11
|
+
items = []
|
|
12
|
+
items.concat(global_variables_inventory)
|
|
13
|
+
items.concat(constant_registry_hints)
|
|
14
|
+
items.first(max_entries)
|
|
15
|
+
rescue StandardError => e
|
|
16
|
+
[{ error: e.message }]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def global_variables_inventory
|
|
20
|
+
global_variables.map do |name|
|
|
21
|
+
next if name == :$= # deprecated / noisy
|
|
22
|
+
|
|
23
|
+
value = begin
|
|
24
|
+
eval(name.to_s) # rubocop:disable Security/Eval -- inspecting known global variable names only
|
|
25
|
+
rescue StandardError
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
next if value.nil?
|
|
29
|
+
|
|
30
|
+
{
|
|
31
|
+
kind: :global_variable,
|
|
32
|
+
name: name.to_s,
|
|
33
|
+
class: value.class.name,
|
|
34
|
+
size_hint: size_hint(value),
|
|
35
|
+
shallow_bytes: Runtime.current.memsize_of(value)
|
|
36
|
+
}
|
|
37
|
+
end.compact
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def constant_registry_hints
|
|
41
|
+
suspects = []
|
|
42
|
+
Object.constants.each do |const_name|
|
|
43
|
+
const = begin
|
|
44
|
+
Object.const_get(const_name)
|
|
45
|
+
rescue StandardError
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
next unless const.is_a?(Module)
|
|
49
|
+
|
|
50
|
+
%i[REGISTRY CACHE STORE HANDLERS LISTENERS SUBSCRIBERS].each do |hint|
|
|
51
|
+
next unless const.const_defined?(hint, false)
|
|
52
|
+
|
|
53
|
+
value = const.const_get(hint)
|
|
54
|
+
suspects << {
|
|
55
|
+
kind: :constant,
|
|
56
|
+
name: "#{const.name}::#{hint}",
|
|
57
|
+
class: value.class.name,
|
|
58
|
+
size_hint: size_hint(value),
|
|
59
|
+
shallow_bytes: Runtime.current.memsize_of(value)
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
rescue StandardError
|
|
63
|
+
next
|
|
64
|
+
end
|
|
65
|
+
suspects
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def growing_findings(previous:, current:)
|
|
69
|
+
findings = []
|
|
70
|
+
prev_map = previous.to_h { |i| [i[:name], i] }
|
|
71
|
+
current.each do |item|
|
|
72
|
+
before = prev_map[item[:name]]
|
|
73
|
+
next unless before
|
|
74
|
+
|
|
75
|
+
before_size = before[:size_hint].to_i
|
|
76
|
+
after_size = item[:size_hint].to_i
|
|
77
|
+
next unless after_size > before_size + 50
|
|
78
|
+
|
|
79
|
+
findings << Finding.new(
|
|
80
|
+
code: "HS004",
|
|
81
|
+
severity: after_size > before_size * 2 ? :high : :medium,
|
|
82
|
+
subject: item[:name],
|
|
83
|
+
facts: [
|
|
84
|
+
"Observed fact: #{item[:name]} size hint #{before_size} → #{after_size}."
|
|
85
|
+
],
|
|
86
|
+
derived: ["Derived: global/constant collection grew between samples."],
|
|
87
|
+
hypothesis: "A module-level registry or global collection may be accumulating entries.",
|
|
88
|
+
suspected_cause: item[:name],
|
|
89
|
+
suggestions: [
|
|
90
|
+
"Bound or clear #{item[:name]}",
|
|
91
|
+
"Confirm intentional caching vs accidental append"
|
|
92
|
+
],
|
|
93
|
+
evidence: [{ before: before, after: item }]
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
findings
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def size_hint(value)
|
|
100
|
+
case value
|
|
101
|
+
when Array, Hash, Set then value.size
|
|
102
|
+
else 1
|
|
103
|
+
end
|
|
104
|
+
rescue StandardError
|
|
105
|
+
0
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# Bounded reachability / retention-path analysis for deep mode.
|
|
5
|
+
# Never fabricates roots; reports nearest observed retainer when uncertain.
|
|
6
|
+
class Graph
|
|
7
|
+
Node = Struct.new(:local_id, :class_name, :ruby_object_id, :label, keyword_init: true)
|
|
8
|
+
Edge = Struct.new(:from, :to, :via, keyword_init: true)
|
|
9
|
+
Path = Struct.new(:nodes, :confidence, :note, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
def initialize(runtime: Runtime.current, config: HeapScope.config)
|
|
12
|
+
@runtime = runtime
|
|
13
|
+
@config = config
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def available?
|
|
17
|
+
@runtime.reachable_objects?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# BFS for a short human-understandable retention path from known roots.
|
|
21
|
+
def retention_path(target, roots: nil, max_depth: nil, max_edges: nil)
|
|
22
|
+
max_depth ||= @config.max_graph_depth
|
|
23
|
+
max_edges ||= @config.max_edges
|
|
24
|
+
return unavailable_path("reachable_objects API unavailable") unless available?
|
|
25
|
+
|
|
26
|
+
roots ||= default_roots
|
|
27
|
+
visited = {}
|
|
28
|
+
parent = {}
|
|
29
|
+
via = {}
|
|
30
|
+
queue = []
|
|
31
|
+
edges = 0
|
|
32
|
+
target_id = target.__id__
|
|
33
|
+
|
|
34
|
+
roots.each do |root, label|
|
|
35
|
+
id = root.__id__
|
|
36
|
+
next if visited[id]
|
|
37
|
+
|
|
38
|
+
visited[id] = true
|
|
39
|
+
queue << root
|
|
40
|
+
parent[id] = nil
|
|
41
|
+
via[id] = label
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
found = nil
|
|
45
|
+
while (current = queue.shift)
|
|
46
|
+
break if edges >= max_edges
|
|
47
|
+
|
|
48
|
+
depth = depth_of(current.__id__, parent)
|
|
49
|
+
next if depth >= max_depth
|
|
50
|
+
|
|
51
|
+
children = safe_reachable(current)
|
|
52
|
+
children.each_with_index do |child, idx|
|
|
53
|
+
edges += 1
|
|
54
|
+
break if edges >= max_edges
|
|
55
|
+
|
|
56
|
+
cid = child.__id__
|
|
57
|
+
next if visited[cid]
|
|
58
|
+
|
|
59
|
+
visited[cid] = true
|
|
60
|
+
parent[cid] = current.__id__
|
|
61
|
+
via[cid] = edge_label(current, child, idx)
|
|
62
|
+
queue << child
|
|
63
|
+
if cid == target_id
|
|
64
|
+
found = child
|
|
65
|
+
break
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
break if found
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if found
|
|
72
|
+
build_path(found, parent, via, :high, "Bounded BFS path from known roots")
|
|
73
|
+
else
|
|
74
|
+
nearest = nearest_retainer(target)
|
|
75
|
+
Path.new(
|
|
76
|
+
nodes: nearest,
|
|
77
|
+
confidence: :low,
|
|
78
|
+
note: "Nearest observed retainer; true root not identified within budget"
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
rescue AnalysisLimitError => e
|
|
82
|
+
Path.new(nodes: [], confidence: :low, note: e.message)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def estimate_retained_size(root, max_objects: nil, max_depth: nil)
|
|
86
|
+
max_objects ||= [@config.max_objects, 10_000].min
|
|
87
|
+
max_depth ||= @config.max_graph_depth
|
|
88
|
+
return { shallow: @runtime.memsize_of(root), retained: nil, note: "unavailable" } unless available?
|
|
89
|
+
|
|
90
|
+
seen = {}
|
|
91
|
+
total = 0
|
|
92
|
+
count = 0
|
|
93
|
+
queue = [[root, 0]]
|
|
94
|
+
seen[root.__id__] = true
|
|
95
|
+
|
|
96
|
+
while (pair = queue.shift)
|
|
97
|
+
obj, depth = pair
|
|
98
|
+
count += 1
|
|
99
|
+
break if count >= max_objects
|
|
100
|
+
|
|
101
|
+
total += @runtime.memsize_of(obj).to_i
|
|
102
|
+
next if depth >= max_depth
|
|
103
|
+
|
|
104
|
+
safe_reachable(obj).each do |child|
|
|
105
|
+
cid = child.__id__
|
|
106
|
+
next if seen[cid]
|
|
107
|
+
|
|
108
|
+
seen[cid] = true
|
|
109
|
+
queue << [child, depth + 1]
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
{
|
|
114
|
+
shallow: @runtime.memsize_of(root),
|
|
115
|
+
retained: total,
|
|
116
|
+
objects: count,
|
|
117
|
+
approximate: true,
|
|
118
|
+
note: "Approximate retained size via bounded traversal"
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def thread_local_inventory
|
|
123
|
+
Thread.list.map do |thread|
|
|
124
|
+
keys =
|
|
125
|
+
if thread.respond_to?(:keys)
|
|
126
|
+
thread.keys
|
|
127
|
+
else
|
|
128
|
+
[]
|
|
129
|
+
end
|
|
130
|
+
values = keys.each_with_object({}) do |key, hash|
|
|
131
|
+
value = thread[key]
|
|
132
|
+
hash[key.inspect] = {
|
|
133
|
+
class: value.class.name,
|
|
134
|
+
shallow_bytes: @runtime.memsize_of(value)
|
|
135
|
+
}
|
|
136
|
+
rescue StandardError
|
|
137
|
+
hash[key.inspect] = { class: "unknown" }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
{
|
|
141
|
+
name: thread.name || "thread-#{thread.object_id}",
|
|
142
|
+
status: thread.status,
|
|
143
|
+
keys: values
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def unbounded_collection_candidates(_samples)
|
|
149
|
+
# samples: array of { object_local_id/class/size } or class series sizes for Array/Hash
|
|
150
|
+
[]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def default_roots
|
|
156
|
+
roots = []
|
|
157
|
+
roots << [Object, "constant:Object"]
|
|
158
|
+
begin
|
|
159
|
+
Thread.list.each { |t| roots << [t, "thread:#{t.name || t.object_id}"] }
|
|
160
|
+
rescue StandardError
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
roots << [Fiber.current, "fiber:current"] if defined?(Fiber) && Fiber.respond_to?(:current)
|
|
164
|
+
roots
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def safe_reachable(object)
|
|
168
|
+
@runtime.reachable_objects_from(object).grep(Object)
|
|
169
|
+
rescue StandardError
|
|
170
|
+
[]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def edge_label(parent, _child, index)
|
|
174
|
+
case parent
|
|
175
|
+
when Hash then "hash_entry"
|
|
176
|
+
when Array then "array[#{index}]"
|
|
177
|
+
when Thread then "thread_reference"
|
|
178
|
+
else "ivars/refs"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def depth_of(id, parent)
|
|
183
|
+
d = 0
|
|
184
|
+
cur = id
|
|
185
|
+
while parent[cur]
|
|
186
|
+
d += 1
|
|
187
|
+
cur = parent[cur]
|
|
188
|
+
break if d > 10_000
|
|
189
|
+
end
|
|
190
|
+
d
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def build_path(target, parent, via, confidence, note)
|
|
194
|
+
cur = target.__id__
|
|
195
|
+
chain = []
|
|
196
|
+
while cur
|
|
197
|
+
chain << cur
|
|
198
|
+
cur = parent[cur]
|
|
199
|
+
end
|
|
200
|
+
nodes = chain.reverse.map do |id|
|
|
201
|
+
{ object_id: id, via: via[id] }
|
|
202
|
+
end
|
|
203
|
+
Path.new(nodes: nodes, confidence: confidence, note: note)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def nearest_retainer(target)
|
|
207
|
+
refs = safe_reachable(target)
|
|
208
|
+
[{ class: target.class.name, note: "target" }] +
|
|
209
|
+
refs.first(5).map { |o| { class: o.class.name, note: "reachable_neighbor" } }
|
|
210
|
+
rescue StandardError
|
|
211
|
+
[{ class: target.class.name, note: "target_only" }]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def unavailable_path(note)
|
|
215
|
+
Path.new(nodes: [], confidence: :low, note: note)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|