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,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