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,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# Explicit memory budgets for CI and regression tests. Integers only — no AS core_ext required.
|
|
5
|
+
class Budget
|
|
6
|
+
PRESETS = {
|
|
7
|
+
rails_request: {
|
|
8
|
+
max_retained_objects: 5_000,
|
|
9
|
+
max_retained_bytes: 8 * 1024 * 1024,
|
|
10
|
+
max_retention_ratio: 0.15,
|
|
11
|
+
max_rss_growth: 20 * 1024 * 1024,
|
|
12
|
+
max_heap_live_growth: 50_000,
|
|
13
|
+
severity_threshold: :high
|
|
14
|
+
},
|
|
15
|
+
sidekiq_job: {
|
|
16
|
+
max_retained_objects: 10_000,
|
|
17
|
+
max_retained_bytes: 16 * 1024 * 1024,
|
|
18
|
+
max_retention_ratio: 0.20,
|
|
19
|
+
max_rss_growth: 50 * 1024 * 1024,
|
|
20
|
+
max_heap_live_growth: 100_000,
|
|
21
|
+
severity_threshold: :high
|
|
22
|
+
},
|
|
23
|
+
ci_strict: {
|
|
24
|
+
max_retained_objects: 500,
|
|
25
|
+
max_retained_bytes: 2 * 1024 * 1024,
|
|
26
|
+
max_retention_ratio: 0.05,
|
|
27
|
+
max_rss_growth: 5 * 1024 * 1024,
|
|
28
|
+
max_heap_live_growth: 5_000,
|
|
29
|
+
severity_threshold: :medium
|
|
30
|
+
}
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
attr_reader :max_retained_objects, :max_retained_bytes, :max_retention_ratio,
|
|
34
|
+
:max_rss_growth, :max_heap_live_growth, :severity_threshold, :preset_name
|
|
35
|
+
|
|
36
|
+
def self.preset(name)
|
|
37
|
+
key = name.to_sym
|
|
38
|
+
attrs = PRESETS[key]
|
|
39
|
+
raise ArgumentError, "Unknown budget preset: #{name} (#{PRESETS.keys.join(', ')})" unless attrs
|
|
40
|
+
|
|
41
|
+
new(**attrs, preset_name: key)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.presets
|
|
45
|
+
PRESETS.keys
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(max_retained_objects: nil, max_retained_bytes: nil,
|
|
49
|
+
max_retention_ratio: nil, max_rss_growth: nil,
|
|
50
|
+
max_heap_live_growth: nil, severity_threshold: :high,
|
|
51
|
+
preset_name: nil)
|
|
52
|
+
@max_retained_objects = max_retained_objects
|
|
53
|
+
@max_retained_bytes = max_retained_bytes
|
|
54
|
+
@max_retention_ratio = max_retention_ratio
|
|
55
|
+
@max_rss_growth = max_rss_growth
|
|
56
|
+
@max_heap_live_growth = max_heap_live_growth
|
|
57
|
+
@severity_threshold = severity_threshold
|
|
58
|
+
@preset_name = preset_name
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def evaluate(report)
|
|
62
|
+
violations = []
|
|
63
|
+
diff = report.diff
|
|
64
|
+
|
|
65
|
+
if max_retained_objects && diff
|
|
66
|
+
surviving = diff.surviving_estimate || report.classes.sum { |c| [c[:delta_count].to_i, 0].max }
|
|
67
|
+
violations << "Retained objects #{surviving} exceed budget #{max_retained_objects}" if surviving && surviving > max_retained_objects
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if max_retained_bytes && diff
|
|
71
|
+
bytes = diff.heap_bytes_estimate_delta
|
|
72
|
+
violations << "Retained bytes #{bytes} exceed budget #{max_retained_bytes}" if bytes > max_retained_bytes
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
if max_retention_ratio && diff&.retention_ratio && (diff.retention_ratio > max_retention_ratio)
|
|
76
|
+
violations << "Retention ratio #{diff.retention_ratio} exceeds budget #{max_retention_ratio}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
violations << "RSS growth #{diff.rss_delta} exceeds budget #{max_rss_growth}" if max_rss_growth && diff&.rss_delta && diff.rss_delta > max_rss_growth
|
|
80
|
+
|
|
81
|
+
if max_heap_live_growth && diff&.heap_live_delta && diff.heap_live_delta > max_heap_live_growth
|
|
82
|
+
violations << "Heap live growth #{diff.heap_live_delta} exceeds budget #{max_heap_live_growth}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
rank = { low: 1, medium: 2, high: 3 }
|
|
86
|
+
threshold = rank[severity_threshold] || 3
|
|
87
|
+
report.findings.each do |f|
|
|
88
|
+
next if (rank[f.severity] || 0) < threshold
|
|
89
|
+
|
|
90
|
+
violations << "Finding #{f.code} severity #{f.severity} at/above threshold #{severity_threshold}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
{ passed: violations.empty?, violations: violations, preset: preset_name }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def to_h
|
|
97
|
+
{
|
|
98
|
+
preset: preset_name,
|
|
99
|
+
max_retained_objects: max_retained_objects,
|
|
100
|
+
max_retained_bytes: max_retained_bytes,
|
|
101
|
+
max_retention_ratio: max_retention_ratio,
|
|
102
|
+
max_rss_growth: max_rss_growth,
|
|
103
|
+
max_heap_live_growth: max_heap_live_growth,
|
|
104
|
+
severity_threshold: severity_threshold
|
|
105
|
+
}.compact
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# Runtime capability probe. Never claims unavailable features exist.
|
|
5
|
+
class Capabilities
|
|
6
|
+
ATTRS = %i[
|
|
7
|
+
engine
|
|
8
|
+
ruby_version
|
|
9
|
+
allocation_tracing
|
|
10
|
+
reachable_objects
|
|
11
|
+
memsize
|
|
12
|
+
rss_tracking
|
|
13
|
+
each_object
|
|
14
|
+
gc_stat
|
|
15
|
+
dump_all
|
|
16
|
+
object_id_stable_across_restart
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
attr_reader(*ATTRS)
|
|
20
|
+
|
|
21
|
+
def initialize(runtime)
|
|
22
|
+
@engine = runtime.engine
|
|
23
|
+
@ruby_version = RUBY_VERSION
|
|
24
|
+
@allocation_tracing = runtime.allocation_tracing?
|
|
25
|
+
@reachable_objects = runtime.reachable_objects?
|
|
26
|
+
@memsize = runtime.memsize?
|
|
27
|
+
@rss_tracking = runtime.rss_tracking?
|
|
28
|
+
@each_object = runtime.each_object?
|
|
29
|
+
@gc_stat = runtime.gc_stat?
|
|
30
|
+
@dump_all = false
|
|
31
|
+
@object_id_stable_across_restart = false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_h
|
|
35
|
+
ATTRS.to_h { |key| [key, public_send(key)] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_s
|
|
39
|
+
to_h.map { |k, v| "#{k}: #{v}" }.join("\n")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# Catalog of diagnostic codes for CLI / docs generation.
|
|
5
|
+
module Catalog
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def codes
|
|
9
|
+
Diagnostics::CODES.values
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def lookup(code)
|
|
13
|
+
key = normalize_code(code)
|
|
14
|
+
Diagnostics::CODES[key]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def explain(code)
|
|
18
|
+
meta = lookup(code)
|
|
19
|
+
raise ArgumentError, "Unknown diagnostic code: #{code}" unless meta
|
|
20
|
+
|
|
21
|
+
doc = encyclopedia_text(meta)
|
|
22
|
+
return doc if doc
|
|
23
|
+
|
|
24
|
+
fallback_text(meta)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_text
|
|
28
|
+
lines = [Branding.compact_banner, "", "Diagnostic codes", ""]
|
|
29
|
+
codes.each do |c|
|
|
30
|
+
lines << "#{c[:id]} #{c[:name]}"
|
|
31
|
+
lines << " #{c[:title]} (default severity: #{c[:default_severity]})"
|
|
32
|
+
lines << ""
|
|
33
|
+
end
|
|
34
|
+
lines << Branding.funding_lines.join("\n")
|
|
35
|
+
lines.join("\n")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def normalize_code(code)
|
|
39
|
+
raw = code.to_s.strip.upcase
|
|
40
|
+
return raw if Diagnostics::CODES.key?(raw)
|
|
41
|
+
return "HS#{raw}" if raw.match?(/\A\d{3}\z/)
|
|
42
|
+
return format("HS%03d", raw.to_i) if raw.match?(/\A\d+\z/)
|
|
43
|
+
|
|
44
|
+
raw
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def encyclopedia_path(meta)
|
|
48
|
+
root = File.expand_path("../../..", __dir__)
|
|
49
|
+
File.join(root, "docs", "diagnostics", "#{meta[:id]}_#{meta[:name]}.md")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def encyclopedia_text(meta)
|
|
53
|
+
path = encyclopedia_path(meta)
|
|
54
|
+
return nil unless File.file?(path)
|
|
55
|
+
|
|
56
|
+
File.read(path)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def fallback_text(meta)
|
|
60
|
+
<<~TEXT
|
|
61
|
+
#{meta[:id]} — #{meta[:name]}
|
|
62
|
+
Title: #{meta[:title]}
|
|
63
|
+
Default severity: #{meta[:default_severity]}
|
|
64
|
+
|
|
65
|
+
See docs/diagnostics/#{meta[:id]}_#{meta[:name]}.md when packaged with the gem.
|
|
66
|
+
#{Branding.funding_lines.join("\n")}
|
|
67
|
+
TEXT
|
|
68
|
+
end
|
|
69
|
+
private_class_method :encyclopedia_path, :encyclopedia_text, :fallback_text
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
class CLI
|
|
5
|
+
# Optional ANSI color for TTY output. Respects NO_COLOR and --no-color.
|
|
6
|
+
module Color
|
|
7
|
+
RESET = "\e[0m"
|
|
8
|
+
BOLD = "\e[1m"
|
|
9
|
+
DIM = "\e[2m"
|
|
10
|
+
RED = "\e[31m"
|
|
11
|
+
GREEN = "\e[32m"
|
|
12
|
+
YELLOW = "\e[33m"
|
|
13
|
+
CYAN = "\e[36m"
|
|
14
|
+
MAGENTA = "\e[35m"
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def enabled?(force: nil)
|
|
19
|
+
return force unless force.nil?
|
|
20
|
+
return false if ENV["NO_COLOR"] && !ENV["NO_COLOR"].empty?
|
|
21
|
+
return false if ENV["TERM"].to_s == "dumb"
|
|
22
|
+
|
|
23
|
+
$stdout.tty?
|
|
24
|
+
rescue StandardError
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def wrap(text, code, enabled:)
|
|
29
|
+
return text.to_s unless enabled && code
|
|
30
|
+
|
|
31
|
+
"#{code}#{text}#{RESET}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def severity(sev, enabled:)
|
|
35
|
+
s = sev.to_s.upcase
|
|
36
|
+
code =
|
|
37
|
+
case sev.to_s.downcase.to_sym
|
|
38
|
+
when :high then RED
|
|
39
|
+
when :medium then YELLOW
|
|
40
|
+
when :low then CYAN
|
|
41
|
+
else DIM
|
|
42
|
+
end
|
|
43
|
+
wrap(s, code, enabled: enabled)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def ok(text, enabled:)
|
|
47
|
+
wrap(text, GREEN, enabled: enabled)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def warn_text(text, enabled:)
|
|
51
|
+
wrap(text, YELLOW, enabled: enabled)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def err(text, enabled:)
|
|
55
|
+
wrap(text, RED, enabled: enabled)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def accent(text, enabled:)
|
|
59
|
+
wrap(text, CYAN, enabled: enabled)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def bold(text, enabled:)
|
|
63
|
+
wrap(text, BOLD, enabled: enabled)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
class CLI
|
|
5
|
+
# Capture / live-process commands.
|
|
6
|
+
module Commands
|
|
7
|
+
module Capture
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def cmd_snapshot(argv)
|
|
11
|
+
return help_exit("snapshot") if command_wants_help?(argv)
|
|
12
|
+
|
|
13
|
+
options = { mode: :standard, output: "heapscope-snapshot.json", slim: false }
|
|
14
|
+
OptionParser.new do |opts|
|
|
15
|
+
opts.banner = "Usage: heapscope snapshot [options]"
|
|
16
|
+
opts.on("--mode MODE", %w[lightweight standard deep], "Snapshot mode") { |m| options[:mode] = m.to_sym }
|
|
17
|
+
opts.on("-o", "--output PATH", "Output JSON path") { |p| options[:output] = p }
|
|
18
|
+
opts.on("--slim", "Write slim JSON (top classes only)") { options[:slim] = true }
|
|
19
|
+
opts.on("--force-gc", "Force GC before capture") { options[:force_gc] = true }
|
|
20
|
+
opts.on("--track-allocations", "Enable allocation tracing for this capture") { options[:track_allocations] = true }
|
|
21
|
+
opts.on("--verbose", "Verbose logging") { HeapScope.config.verbose = true }
|
|
22
|
+
opts.on("-h", "--help", "Show help") { options[:help] = true }
|
|
23
|
+
end.parse!(argv)
|
|
24
|
+
return help_exit("snapshot") if options[:help]
|
|
25
|
+
|
|
26
|
+
HeapScope.config.track_allocations = true if options[:track_allocations]
|
|
27
|
+
GC.start if options[:force_gc]
|
|
28
|
+
snap = HeapScope.snapshot(mode: options[:mode])
|
|
29
|
+
snap.save(options[:output], slim: options[:slim])
|
|
30
|
+
if json_mode?
|
|
31
|
+
emit_json(snap.to_h(slim: options[:slim]).merge(saved_to: options[:output]))
|
|
32
|
+
else
|
|
33
|
+
say "Wrote snapshot #{snap.id} (mode=#{snap.mode}#{options[:slim] ? ', slim' : ''}) to #{options[:output]}"
|
|
34
|
+
say "Limitations: #{snap.limitations.join(', ')}" unless snap.limitations.empty?
|
|
35
|
+
end
|
|
36
|
+
EXIT_OK
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def cmd_inspect(argv)
|
|
40
|
+
return help_exit("inspect") if command_wants_help?(argv)
|
|
41
|
+
|
|
42
|
+
options = { mode: :standard, top: 30 }
|
|
43
|
+
OptionParser.new do |opts|
|
|
44
|
+
opts.banner = "Usage: heapscope inspect [capture.json] [options]"
|
|
45
|
+
opts.on("--mode MODE", %w[lightweight standard deep]) { |m| options[:mode] = m.to_sym }
|
|
46
|
+
opts.on("--html PATH", "Also write HTML") { |p| options[:html] = p }
|
|
47
|
+
opts.on("--top N", Integer, "Top class rows") { |n| options[:top] = n }
|
|
48
|
+
opts.on("--markdown", "Markdown output") { options[:markdown] = true }
|
|
49
|
+
opts.on("--json", "JSON output") { options[:json] = true }
|
|
50
|
+
parse_fail_on!(options, opts)
|
|
51
|
+
opts.on("-h", "--help") { options[:help] = true }
|
|
52
|
+
end.parse!(argv)
|
|
53
|
+
return help_exit("inspect") if options[:help]
|
|
54
|
+
|
|
55
|
+
path = argv.shift
|
|
56
|
+
report =
|
|
57
|
+
if path
|
|
58
|
+
raise InvalidReportError, "File not found: #{path}" unless File.exist?(path)
|
|
59
|
+
|
|
60
|
+
data = JSON.parse(File.read(path), symbolize_names: true)
|
|
61
|
+
if data[:schema_version] && data[:findings]
|
|
62
|
+
Report.from_h(data)
|
|
63
|
+
else
|
|
64
|
+
snap = Snapshot.from_h(data)
|
|
65
|
+
Report.new(
|
|
66
|
+
summary: { healthy: true },
|
|
67
|
+
after: snap,
|
|
68
|
+
classes: snap.top_classes(options[:top]),
|
|
69
|
+
metadata: { kind: "inspect_capture" },
|
|
70
|
+
limitations: snap.limitations,
|
|
71
|
+
runtime_info: { ruby: RUBY_VERSION, engine: RUBY_ENGINE }
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
else
|
|
75
|
+
say_err "Note: remote PID attachment is not supported; inspecting current process." unless quiet?
|
|
76
|
+
before = HeapScope.snapshot(mode: :lightweight)
|
|
77
|
+
GC.start
|
|
78
|
+
after = HeapScope.snapshot(mode: options[:mode])
|
|
79
|
+
HeapScope.compare(before, after, metadata: { kind: "inspect" })
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
print_report_views(report, options)
|
|
83
|
+
write_outputs(report, options)
|
|
84
|
+
exit_for_findings(report, fail_on: options[:fail_on])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def cmd_monitor(argv)
|
|
88
|
+
return help_exit("monitor") if command_wants_help?(argv)
|
|
89
|
+
|
|
90
|
+
options = {
|
|
91
|
+
interval: 10,
|
|
92
|
+
duration: 60,
|
|
93
|
+
mode: :lightweight,
|
|
94
|
+
output: "heapscope-monitor.json",
|
|
95
|
+
alert: false,
|
|
96
|
+
rss_alert_bytes: 25 * 1024 * 1024,
|
|
97
|
+
live_alert_slots: 80_000
|
|
98
|
+
}
|
|
99
|
+
OptionParser.new do |opts|
|
|
100
|
+
opts.banner = "Usage: heapscope monitor [options]"
|
|
101
|
+
opts.on("--interval SEC", Integer) { |n| options[:interval] = n }
|
|
102
|
+
opts.on("--duration SEC", Integer) { |n| options[:duration] = n }
|
|
103
|
+
opts.on("--mode MODE", %w[lightweight standard]) { |m| options[:mode] = m.to_sym }
|
|
104
|
+
opts.on("--alert", "Emit anomaly alerts on RSS/live-slot spikes") { options[:alert] = true }
|
|
105
|
+
opts.on("--rss-alert-bytes N", Integer) { |n| options[:rss_alert_bytes] = n }
|
|
106
|
+
opts.on("--live-alert-slots N", Integer) { |n| options[:live_alert_slots] = n }
|
|
107
|
+
opts.on("-o", "--output PATH") { |p| options[:output] = p }
|
|
108
|
+
opts.on("--html PATH") { |p| options[:html] = p }
|
|
109
|
+
parse_fail_on!(options, opts)
|
|
110
|
+
opts.on("-h", "--help") { options[:help] = true }
|
|
111
|
+
end.parse!(argv)
|
|
112
|
+
return help_exit("monitor") if options[:help]
|
|
113
|
+
|
|
114
|
+
raise ArgumentError, "interval too aggressive (min 2s)" if options[:interval] < 2
|
|
115
|
+
|
|
116
|
+
monitor = Monitor.start(
|
|
117
|
+
interval: options[:interval],
|
|
118
|
+
mode: options[:mode],
|
|
119
|
+
alert: options[:alert],
|
|
120
|
+
rss_alert_bytes: options[:rss_alert_bytes],
|
|
121
|
+
live_alert_slots: options[:live_alert_slots]
|
|
122
|
+
)
|
|
123
|
+
say "Monitoring for #{options[:duration]}s every #{options[:interval]}s#{options[:alert] ? ' (alerts on)' : ''}..."
|
|
124
|
+
sleep options[:duration]
|
|
125
|
+
report = monitor.stop
|
|
126
|
+
report.save(options[:output])
|
|
127
|
+
report.save_html(options[:html]) if options[:html]
|
|
128
|
+
if json_mode?
|
|
129
|
+
emit_json(report.to_h.merge(saved_to: options[:output]))
|
|
130
|
+
else
|
|
131
|
+
puts report.to_text unless quiet?
|
|
132
|
+
say "Wrote #{options[:output]}"
|
|
133
|
+
say "Alerts: #{monitor.alerts.size}" if options[:alert]
|
|
134
|
+
end
|
|
135
|
+
exit_for_findings(report, fail_on: options[:fail_on])
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def cmd_watch(argv)
|
|
139
|
+
argv = ["--alert", *argv] unless argv.include?("--alert") || argv.include?("--help") || argv.include?("-h")
|
|
140
|
+
cmd_monitor(argv)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def cmd_probe(argv)
|
|
144
|
+
return help_exit("probe") if command_wants_help?(argv)
|
|
145
|
+
|
|
146
|
+
options = { title: "probe", mode: :lightweight, force_gc: true }
|
|
147
|
+
OptionParser.new do |opts|
|
|
148
|
+
opts.banner = "Usage: heapscope probe --file script.rb|--eval CODE [options]"
|
|
149
|
+
opts.on("--file PATH") { |p| options[:file] = p }
|
|
150
|
+
opts.on("--eval CODE") { |c| options[:eval] = c }
|
|
151
|
+
opts.on("--title NAME") { |t| options[:title] = t }
|
|
152
|
+
opts.on("--mode MODE", %w[lightweight standard deep]) { |m| options[:mode] = m.to_sym }
|
|
153
|
+
opts.on("--force-gc") { options[:force_gc] = true }
|
|
154
|
+
opts.on("--no-force-gc") { options[:force_gc] = false }
|
|
155
|
+
opts.on("-o", "--output PATH") { |p| options[:output] = p }
|
|
156
|
+
opts.on("--html PATH") { |p| options[:html] = p }
|
|
157
|
+
opts.on("--json") { options[:json] = true }
|
|
158
|
+
parse_fail_on!(options, opts)
|
|
159
|
+
opts.on("-h", "--help") { options[:help] = true }
|
|
160
|
+
end.parse!(argv)
|
|
161
|
+
return help_exit("probe") if options[:help]
|
|
162
|
+
|
|
163
|
+
result = HeapScope.probe(
|
|
164
|
+
title: options[:title],
|
|
165
|
+
force_gc: options[:force_gc],
|
|
166
|
+
mode: options[:mode],
|
|
167
|
+
print: !(json_mode? || options[:json] || quiet?)
|
|
168
|
+
) { run_file_or_eval!(options) }
|
|
169
|
+
|
|
170
|
+
report = result.report
|
|
171
|
+
write_outputs(report, options)
|
|
172
|
+
emit_json(report.scorecard(title: options[:title]).to_h.merge(report: report.to_h)) if json_mode? || options[:json]
|
|
173
|
+
exit_for_findings(report, fail_on: options[:fail_on])
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def cmd_measure(argv)
|
|
177
|
+
return help_exit("measure") if command_wants_help?(argv)
|
|
178
|
+
|
|
179
|
+
options = { mode: :standard, force_gc: HeapScope.config.force_gc_default }
|
|
180
|
+
OptionParser.new do |opts|
|
|
181
|
+
opts.banner = "Usage: heapscope measure --file script.rb|--eval CODE [options]"
|
|
182
|
+
opts.on("--file PATH") { |p| options[:file] = p }
|
|
183
|
+
opts.on("--eval CODE") { |c| options[:eval] = c }
|
|
184
|
+
opts.on("--mode MODE", %w[lightweight standard deep]) { |m| options[:mode] = m.to_sym }
|
|
185
|
+
opts.on("--force-gc") { options[:force_gc] = true }
|
|
186
|
+
opts.on("--no-force-gc") { options[:force_gc] = false }
|
|
187
|
+
opts.on("--recovery-wait SEC", Float) { |n| options[:recovery_wait] = n }
|
|
188
|
+
opts.on("--track-allocations") { options[:track_allocations] = true }
|
|
189
|
+
opts.on("-o", "--output PATH") { |p| options[:output] = p }
|
|
190
|
+
opts.on("--html PATH") { |p| options[:html] = p }
|
|
191
|
+
opts.on("--markdown") { options[:markdown] = true }
|
|
192
|
+
opts.on("--json") { options[:json] = true }
|
|
193
|
+
parse_fail_on!(options, opts)
|
|
194
|
+
opts.on("-h", "--help") { options[:help] = true }
|
|
195
|
+
end.parse!(argv)
|
|
196
|
+
return help_exit("measure") if options[:help]
|
|
197
|
+
|
|
198
|
+
report = HeapScope.measure(
|
|
199
|
+
force_gc: options[:force_gc],
|
|
200
|
+
mode: options[:mode],
|
|
201
|
+
recovery_wait: options[:recovery_wait],
|
|
202
|
+
track_allocations: options[:track_allocations] || HeapScope.config.track_allocations,
|
|
203
|
+
metadata: { kind: "cli_measure" }
|
|
204
|
+
) { run_file_or_eval!(options) }
|
|
205
|
+
|
|
206
|
+
print_report_views(report, options)
|
|
207
|
+
write_outputs(report, options)
|
|
208
|
+
exit_for_findings(report, fail_on: options[:fail_on])
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def cmd_retention(argv)
|
|
212
|
+
return help_exit("retention") if command_wants_help?(argv)
|
|
213
|
+
|
|
214
|
+
options = { cycles: 5, mode: :lightweight, force_gc: true }
|
|
215
|
+
OptionParser.new do |opts|
|
|
216
|
+
opts.banner = "Usage: heapscope retention --file script.rb|--eval CODE [options]"
|
|
217
|
+
opts.on("--cycles N", Integer) { |n| options[:cycles] = n }
|
|
218
|
+
opts.on("--file PATH") { |p| options[:file] = p }
|
|
219
|
+
opts.on("--eval CODE") { |c| options[:eval] = c }
|
|
220
|
+
opts.on("--mode MODE", %w[lightweight standard deep]) { |m| options[:mode] = m.to_sym }
|
|
221
|
+
opts.on("--force-gc") { options[:force_gc] = true }
|
|
222
|
+
opts.on("--no-force-gc") { options[:force_gc] = false }
|
|
223
|
+
opts.on("-o", "--output PATH") { |p| options[:output] = p }
|
|
224
|
+
opts.on("--html PATH") { |p| options[:html] = p }
|
|
225
|
+
opts.on("--markdown") { options[:markdown] = true }
|
|
226
|
+
opts.on("--json") { options[:json] = true }
|
|
227
|
+
parse_fail_on!(options, opts)
|
|
228
|
+
opts.on("-h", "--help") { options[:help] = true }
|
|
229
|
+
end.parse!(argv)
|
|
230
|
+
return help_exit("retention") if options[:help]
|
|
231
|
+
|
|
232
|
+
report = HeapScope.retention_test(
|
|
233
|
+
cycles: options[:cycles],
|
|
234
|
+
force_gc: options[:force_gc],
|
|
235
|
+
mode: options[:mode],
|
|
236
|
+
metadata: { kind: "cli_retention" }
|
|
237
|
+
) { run_file_or_eval!(options) }
|
|
238
|
+
|
|
239
|
+
print_report_views(report, options)
|
|
240
|
+
write_outputs(report, options)
|
|
241
|
+
exit_for_findings(report, fail_on: options[:fail_on])
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def cmd_self_test(argv)
|
|
245
|
+
return help_exit("self-test") if command_wants_help?(argv)
|
|
246
|
+
|
|
247
|
+
options = {}
|
|
248
|
+
OptionParser.new do |opts|
|
|
249
|
+
opts.on("-o", "--output PATH") { |p| options[:output] = p }
|
|
250
|
+
opts.on("--html PATH") { |p| options[:html] = p }
|
|
251
|
+
opts.on("--json") { options[:json] = true }
|
|
252
|
+
opts.on("-h", "--help") { options[:help] = true }
|
|
253
|
+
end.parse!(argv)
|
|
254
|
+
return help_exit("self-test") if options[:help]
|
|
255
|
+
|
|
256
|
+
leak = []
|
|
257
|
+
result = HeapScope.probe(
|
|
258
|
+
title: "self-test retention fixture",
|
|
259
|
+
force_gc: true,
|
|
260
|
+
mode: :lightweight,
|
|
261
|
+
print: !(json_mode? || options[:json] || quiet?)
|
|
262
|
+
) do
|
|
263
|
+
800.times { leak << ("heapscope-self-test-" * 4) }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
report = result.report
|
|
267
|
+
write_outputs(report, options)
|
|
268
|
+
if json_mode? || options[:json]
|
|
269
|
+
emit_json(
|
|
270
|
+
scorecard: report.scorecard(title: "self-test").to_h,
|
|
271
|
+
findings: report.findings.map(&:to_h),
|
|
272
|
+
note: "Intentional retention demo; leak array held #{leak.size} strings."
|
|
273
|
+
)
|
|
274
|
+
else
|
|
275
|
+
say
|
|
276
|
+
say Color.accent("Self-test complete (intentional retention; local-only).", enabled: color?)
|
|
277
|
+
say "Findings: #{report.findings.size} Suspects: #{report.suspects.size}"
|
|
278
|
+
report.findings.each do |f|
|
|
279
|
+
sev = Color.severity(f.severity, enabled: color?)
|
|
280
|
+
say " #{f.code} [#{sev}] #{f.title}"
|
|
281
|
+
end
|
|
282
|
+
say "Privacy: no network, no telemetry."
|
|
283
|
+
end
|
|
284
|
+
EXIT_OK
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|