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,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
class CLI
|
|
5
|
+
# Help text and per-command usage.
|
|
6
|
+
module Help
|
|
7
|
+
COMMANDS = {
|
|
8
|
+
"snapshot" => "Capture a heap snapshot to JSON",
|
|
9
|
+
"inspect" => "Inspect current process (embedded) or a saved capture",
|
|
10
|
+
"diff" => "Diff two snapshots (scorecard + table + report)",
|
|
11
|
+
"monitor" => "Sample the current process for a duration",
|
|
12
|
+
"watch" => "Monitor with anomaly alerts enabled (alias)",
|
|
13
|
+
"trends" => "Summarize a monitor/report timeline",
|
|
14
|
+
"report" => "Render a text/HTML/markdown/JSON report",
|
|
15
|
+
"compare" => "Compare a baseline to a current report",
|
|
16
|
+
"baseline" => "Create a baseline from a report",
|
|
17
|
+
"sessions" => "List saved .heapscope sessions",
|
|
18
|
+
"history" => "Show recent session reports",
|
|
19
|
+
"validate" => "Validate a report JSON schema",
|
|
20
|
+
"suggest" => "Suggest ignore_patterns from a report",
|
|
21
|
+
"ignore-suggest" => "Alias of suggest",
|
|
22
|
+
"pack" => "Export a local JSON+HTML+Markdown report bundle",
|
|
23
|
+
"export" => "Alias of pack",
|
|
24
|
+
"probe" => "Measure a Ruby file/snippet and print a scorecard",
|
|
25
|
+
"measure" => "CLI wrapper around HeapScope.measure",
|
|
26
|
+
"retention" => "CLI wrapper around HeapScope.retention_test",
|
|
27
|
+
"findings" => "List/filter findings from a report",
|
|
28
|
+
"scorecard" => "Print scorecard only from a report",
|
|
29
|
+
"table" => "Print growth table from a report",
|
|
30
|
+
"explain" => "Print diagnostic encyclopedia entry for a code",
|
|
31
|
+
"open" => "Write HTML for a report and print the path",
|
|
32
|
+
"html" => "Alias of open",
|
|
33
|
+
"self-test" => "Run a built-in retention demo and show findings",
|
|
34
|
+
"env" => "Print HeapScope-relevant environment variables",
|
|
35
|
+
"completion" => "Generate bash/zsh/powershell completion scripts",
|
|
36
|
+
"man" => "Extended about + examples",
|
|
37
|
+
"codes" => "List diagnostic codes (HS001–HS010)",
|
|
38
|
+
"doctor" => "Diagnose runtime capabilities & config",
|
|
39
|
+
"overhead" => "Measure HeapScope snapshot overhead",
|
|
40
|
+
"config" => "Load a YAML/Ruby config file",
|
|
41
|
+
"about" => "Branding, docs, and funding links",
|
|
42
|
+
"capabilities" => "Show runtime capabilities",
|
|
43
|
+
"version" => "Print version",
|
|
44
|
+
"help" => "Show help (or help <command>)"
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
module_function
|
|
48
|
+
|
|
49
|
+
def global_help
|
|
50
|
+
lines = []
|
|
51
|
+
lines << Branding.compact_banner
|
|
52
|
+
lines << ""
|
|
53
|
+
lines << "Usage:"
|
|
54
|
+
lines << " heapscope [global options] <command> [options]"
|
|
55
|
+
lines << ""
|
|
56
|
+
lines << "Global options:"
|
|
57
|
+
lines << " --verbose Verbose HeapScope logging"
|
|
58
|
+
lines << " --quiet Suppress non-essential output"
|
|
59
|
+
lines << " --config PATH Load YAML/Ruby config before command"
|
|
60
|
+
lines << " --json Prefer machine-readable JSON on key commands"
|
|
61
|
+
lines << " --no-color Disable ANSI colors"
|
|
62
|
+
lines << " --color Force ANSI colors"
|
|
63
|
+
lines << " -h, --help Show help"
|
|
64
|
+
lines << " -v, --version Print version"
|
|
65
|
+
lines << ""
|
|
66
|
+
lines << "Commands:"
|
|
67
|
+
COMMANDS.each do |name, desc|
|
|
68
|
+
lines << format(" %-14s %s", name, desc)
|
|
69
|
+
end
|
|
70
|
+
lines << ""
|
|
71
|
+
lines << "Exit codes:"
|
|
72
|
+
lines << " 0 success"
|
|
73
|
+
lines << " 1 regression / finding above threshold"
|
|
74
|
+
lines << " 2 invalid input/configuration"
|
|
75
|
+
lines << " 3 runtime capability unavailable"
|
|
76
|
+
lines << ""
|
|
77
|
+
lines << "Privacy: no network, no telemetry, no object value serialization by default."
|
|
78
|
+
lines << "Sponsor: #{Branding::THANKS_DEV_URL}"
|
|
79
|
+
lines << "Docs: #{Branding::PAGES_URL}"
|
|
80
|
+
lines << ""
|
|
81
|
+
lines << "Try: heapscope help <command> or heapscope <command> --help"
|
|
82
|
+
lines.join("\n")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def command_help(name)
|
|
86
|
+
key = name.to_s
|
|
87
|
+
desc = COMMANDS[key]
|
|
88
|
+
return nil unless desc
|
|
89
|
+
|
|
90
|
+
body = USAGE.fetch(key, "Usage: heapscope #{key} [options]\n\n#{desc}")
|
|
91
|
+
"#{Branding.compact_banner}\n\n#{body}\n"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
USAGE = {
|
|
95
|
+
"snapshot" => <<~U,
|
|
96
|
+
Usage: heapscope snapshot [options]
|
|
97
|
+
--mode MODE lightweight|standard|deep
|
|
98
|
+
-o, --output PATH Output JSON path
|
|
99
|
+
--slim Slim JSON (top classes / truncated sites)
|
|
100
|
+
--force-gc Force GC before capture
|
|
101
|
+
--track-allocations Enable allocation tracing for this capture
|
|
102
|
+
U
|
|
103
|
+
"inspect" => <<~U,
|
|
104
|
+
Usage: heapscope inspect [capture.json] [options]
|
|
105
|
+
--mode MODE Snapshot mode when inspecting live process
|
|
106
|
+
--top N Limit class rows in derived views
|
|
107
|
+
--html PATH Also write HTML
|
|
108
|
+
--markdown Print Markdown instead of text
|
|
109
|
+
--json Print JSON
|
|
110
|
+
--fail-on-high Exit 1 on HIGH findings
|
|
111
|
+
U
|
|
112
|
+
"diff" => <<~U,
|
|
113
|
+
Usage: heapscope diff BEFORE.json AFTER.json [options]
|
|
114
|
+
-o, --output PATH Write report JSON
|
|
115
|
+
--html PATH Write HTML report
|
|
116
|
+
--markdown Print Markdown report body
|
|
117
|
+
--top N Limit growth table rows
|
|
118
|
+
--scorecard-only Print scorecard only
|
|
119
|
+
--fail-on-high Exit 1 if HIGH findings
|
|
120
|
+
--fail-on-medium Exit 1 if MEDIUM or HIGH
|
|
121
|
+
--json Print report JSON
|
|
122
|
+
U
|
|
123
|
+
"monitor" => <<~U,
|
|
124
|
+
Usage: heapscope monitor [options]
|
|
125
|
+
--interval SEC Sample interval (min 2)
|
|
126
|
+
--duration SEC Total duration
|
|
127
|
+
--mode MODE lightweight|standard
|
|
128
|
+
--alert Emit RSS / live-slot spike alerts
|
|
129
|
+
--rss-alert-bytes N RSS delta threshold (default 25MB)
|
|
130
|
+
--live-alert-slots N Live-slot delta threshold
|
|
131
|
+
-o, --output PATH Report JSON path
|
|
132
|
+
--html PATH Also write HTML
|
|
133
|
+
--fail-on-high Exit 1 on HIGH findings
|
|
134
|
+
U
|
|
135
|
+
"watch" => <<~U,
|
|
136
|
+
Usage: heapscope watch [options]
|
|
137
|
+
Same as `monitor`, with --alert enabled by default.
|
|
138
|
+
U
|
|
139
|
+
"probe" => <<~U,
|
|
140
|
+
Usage: heapscope probe --file script.rb [options]
|
|
141
|
+
heapscope probe --eval 'ruby code' [options]
|
|
142
|
+
--file PATH Load Ruby file inside measured block
|
|
143
|
+
--eval CODE Eval Ruby snippet inside measured block
|
|
144
|
+
--title NAME Scorecard title
|
|
145
|
+
--mode MODE Snapshot mode
|
|
146
|
+
--force-gc / --no-force-gc
|
|
147
|
+
-o, --output PATH Save report JSON
|
|
148
|
+
--html PATH Save HTML
|
|
149
|
+
--json Print JSON report
|
|
150
|
+
U
|
|
151
|
+
"measure" => <<~U,
|
|
152
|
+
Usage: heapscope measure --file script.rb [options]
|
|
153
|
+
--file PATH / --eval CODE
|
|
154
|
+
--mode MODE --force-gc --recovery-wait SEC
|
|
155
|
+
--track-allocations
|
|
156
|
+
-o, --output PATH --html PATH --json
|
|
157
|
+
--fail-on-high / --fail-on-medium
|
|
158
|
+
U
|
|
159
|
+
"retention" => <<~U,
|
|
160
|
+
Usage: heapscope retention --file script.rb [options]
|
|
161
|
+
--cycles N Retention cycles (default 5)
|
|
162
|
+
--file PATH / --eval CODE
|
|
163
|
+
--mode MODE --force-gc / --no-force-gc
|
|
164
|
+
-o, --output PATH --html PATH --json
|
|
165
|
+
--fail-on-high / --fail-on-medium
|
|
166
|
+
U
|
|
167
|
+
"findings" => <<~U,
|
|
168
|
+
Usage: heapscope findings REPORT.json [options]
|
|
169
|
+
--severity LEVEL Filter: low|medium|high
|
|
170
|
+
--code HS001 Filter by diagnostic code
|
|
171
|
+
--json Machine-readable output
|
|
172
|
+
U
|
|
173
|
+
"scorecard" => "Usage: heapscope scorecard REPORT.json [--json] [--title NAME]\n",
|
|
174
|
+
"table" => "Usage: heapscope table REPORT.json [--markdown] [--top N]\n",
|
|
175
|
+
"explain" => "Usage: heapscope explain CODE\n\nExample: heapscope explain HS001\n",
|
|
176
|
+
"open" => "Usage: heapscope open REPORT.json [-o out.html]\n",
|
|
177
|
+
"html" => "Usage: heapscope html REPORT.json [-o out.html]\n",
|
|
178
|
+
"self-test" => "Usage: heapscope self-test [--json] [-o report.json]\n\nRuns a built-in intentional retention demo (local only).\n",
|
|
179
|
+
"env" => "Usage: heapscope env [--json]\n",
|
|
180
|
+
"completion" => "Usage: heapscope completion bash|zsh|powershell\n",
|
|
181
|
+
"man" => "Usage: heapscope man\n\nExtended branding, privacy notes, and examples.\n",
|
|
182
|
+
"pack" => <<~U,
|
|
183
|
+
Usage: heapscope pack REPORT.json [options]
|
|
184
|
+
-o, --output DIR Output directory
|
|
185
|
+
--label NAME File basename
|
|
186
|
+
U
|
|
187
|
+
"export" => "Alias of pack. See: heapscope help pack\n",
|
|
188
|
+
"suggest" => <<~U,
|
|
189
|
+
Usage: heapscope suggest REPORT.json [options]
|
|
190
|
+
--json next_steps + ignore_patterns
|
|
191
|
+
--ignores-only Print ignore patterns only
|
|
192
|
+
U
|
|
193
|
+
"ignore-suggest" => "Alias of suggest. See: heapscope help suggest\n",
|
|
194
|
+
"report" => <<~U,
|
|
195
|
+
Usage: heapscope report REPORT.json [options]
|
|
196
|
+
--format FMT text|html|json|markdown|md
|
|
197
|
+
-o, --output PATH
|
|
198
|
+
U
|
|
199
|
+
"baseline" => "Usage: heapscope baseline create REPORT.json -o baseline.json\n",
|
|
200
|
+
"compare" => <<~U,
|
|
201
|
+
Usage: heapscope compare BASELINE.json CURRENT.json [options]
|
|
202
|
+
--threshold F Relative growth threshold (default 0.5)
|
|
203
|
+
--json Machine-readable result
|
|
204
|
+
U
|
|
205
|
+
"codes" => "Usage: heapscope codes\n",
|
|
206
|
+
"doctor" => <<~U,
|
|
207
|
+
Usage: heapscope doctor [options]
|
|
208
|
+
--json Machine-readable doctor payload
|
|
209
|
+
--fix Write starter heapscope.yml
|
|
210
|
+
--config-out PATH Path for --fix (default ./heapscope.yml)
|
|
211
|
+
--force Overwrite existing config with --fix
|
|
212
|
+
U
|
|
213
|
+
"overhead" => "Usage: heapscope overhead [--mode MODE] [--runs N] [--json]\n",
|
|
214
|
+
"config" => "Usage: heapscope config PATH\n",
|
|
215
|
+
"about" => "Usage: heapscope about [--json]\n",
|
|
216
|
+
"capabilities" => "Usage: heapscope capabilities [--json]\n",
|
|
217
|
+
"sessions" => "Usage: heapscope sessions\n",
|
|
218
|
+
"history" => "Usage: heapscope history [--limit N]\n",
|
|
219
|
+
"validate" => "Usage: heapscope validate REPORT.json\n",
|
|
220
|
+
"trends" => "Usage: heapscope trends REPORT.json\n",
|
|
221
|
+
"version" => "Usage: heapscope version\n",
|
|
222
|
+
"help" => "Usage: heapscope help [command]\n"
|
|
223
|
+
}.freeze
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
class CLI
|
|
5
|
+
# Shared helpers for option parsing, output, and severity exits.
|
|
6
|
+
module Support
|
|
7
|
+
SEVERITY_RANK = { low: 1, medium: 2, high: 3 }.freeze
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def color?
|
|
12
|
+
@color_enabled
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def json_mode?
|
|
16
|
+
@json_mode
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def quiet?
|
|
20
|
+
HeapScope.config.quiet
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def say(msg = "")
|
|
24
|
+
return if quiet?
|
|
25
|
+
|
|
26
|
+
puts msg
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def say_err(msg)
|
|
30
|
+
warn msg
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def emit_json(payload)
|
|
34
|
+
puts JSON.pretty_generate(payload)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def colorize(text, code)
|
|
38
|
+
Color.wrap(text, code, enabled: color?)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def load_report!(path)
|
|
42
|
+
raise ArgumentError, "report path required" if path.nil? || path.empty?
|
|
43
|
+
raise InvalidReportError, "File not found: #{path}" unless File.exist?(path)
|
|
44
|
+
|
|
45
|
+
Report.load(path)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def load_json_path!(path)
|
|
49
|
+
raise ArgumentError, "path required" if path.nil? || path.empty?
|
|
50
|
+
raise InvalidReportError, "File not found: #{path}" unless File.exist?(path)
|
|
51
|
+
|
|
52
|
+
JSON.parse(File.read(path), symbolize_names: true)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def parse_fail_on!(options, opts)
|
|
56
|
+
opts.on("--fail-on-high", "Exit 1 if HIGH findings present") { options[:fail_on] = :high }
|
|
57
|
+
opts.on("--fail-on-medium", "Exit 1 if MEDIUM or HIGH findings present") { options[:fail_on] = :medium }
|
|
58
|
+
opts.on("--fail-on LEVEL", %w[low medium high], "Exit 1 if findings at/above LEVEL") do |level|
|
|
59
|
+
options[:fail_on] = level.to_sym
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def exit_for_findings(report, fail_on: nil)
|
|
64
|
+
return EXIT_OK unless fail_on
|
|
65
|
+
|
|
66
|
+
threshold = SEVERITY_RANK[fail_on.to_sym]
|
|
67
|
+
raise ArgumentError, "invalid fail-on level: #{fail_on}" unless threshold
|
|
68
|
+
|
|
69
|
+
if report.findings.any? { |f| SEVERITY_RANK.fetch(f.severity.to_sym, 0) >= threshold }
|
|
70
|
+
EXIT_REGRESSION
|
|
71
|
+
else
|
|
72
|
+
EXIT_OK
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def print_report_views(report, options)
|
|
77
|
+
if json_mode? || options[:json]
|
|
78
|
+
emit_json(report.to_h)
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
if options[:scorecard_only]
|
|
83
|
+
say report.scorecard(title: options[:title] || "HeapScope").to_text
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
return if quiet?
|
|
88
|
+
|
|
89
|
+
say report.scorecard(title: options[:title] || "HeapScope").to_text unless options[:no_scorecard]
|
|
90
|
+
if options[:table] != false && report.diff
|
|
91
|
+
say
|
|
92
|
+
say report.table(format: options[:markdown] ? :markdown : :ascii)
|
|
93
|
+
end
|
|
94
|
+
say
|
|
95
|
+
if options[:markdown]
|
|
96
|
+
puts report.to_markdown
|
|
97
|
+
else
|
|
98
|
+
puts report.to_text
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def write_outputs(report, options)
|
|
103
|
+
report.save(options[:output]) if options[:output]
|
|
104
|
+
report.save_html(options[:html]) if options[:html]
|
|
105
|
+
report.save_markdown(options[:markdown_out]) if options[:markdown_out]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def run_file_or_eval!(options)
|
|
109
|
+
if options[:file]
|
|
110
|
+
raise ArgumentError, "File not found: #{options[:file]}" unless File.exist?(options[:file])
|
|
111
|
+
|
|
112
|
+
load(options[:file])
|
|
113
|
+
elsif options[:eval]
|
|
114
|
+
# Intentional: CLI probe/measure of user-provided snippet in local process only.
|
|
115
|
+
eval(options[:eval], TOPLEVEL_BINDING, "(heapscope-eval)") # rubocop:disable Security/Eval
|
|
116
|
+
else
|
|
117
|
+
raise ArgumentError, "provide --file PATH or --eval 'ruby'"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def command_wants_help?(argv)
|
|
122
|
+
argv.any? { |a| a == "-h" || a == "--help" }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
require_relative "cli/color"
|
|
8
|
+
require_relative "cli/support"
|
|
9
|
+
require_relative "cli/help"
|
|
10
|
+
require_relative "cli/completion"
|
|
11
|
+
require_relative "cli/commands/capture"
|
|
12
|
+
require_relative "cli/commands/diffing"
|
|
13
|
+
require_relative "cli/commands/reporting"
|
|
14
|
+
require_relative "cli/commands/meta"
|
|
15
|
+
|
|
16
|
+
module HeapScope
|
|
17
|
+
# Polished, expansive CLI for HeapScope diagnostics (local-only).
|
|
18
|
+
class CLI
|
|
19
|
+
include Support
|
|
20
|
+
include Commands::Capture
|
|
21
|
+
include Commands::Diffing
|
|
22
|
+
include Commands::Reporting
|
|
23
|
+
include Commands::Meta
|
|
24
|
+
|
|
25
|
+
EXIT_OK = 0
|
|
26
|
+
EXIT_REGRESSION = 1
|
|
27
|
+
EXIT_INVALID = 2
|
|
28
|
+
EXIT_CAPABILITY = 3
|
|
29
|
+
|
|
30
|
+
COMMAND_MAP = {
|
|
31
|
+
"snapshot" => :cmd_snapshot,
|
|
32
|
+
"inspect" => :cmd_inspect,
|
|
33
|
+
"diff" => :cmd_diff,
|
|
34
|
+
"monitor" => :cmd_monitor,
|
|
35
|
+
"watch" => :cmd_watch,
|
|
36
|
+
"trends" => :cmd_trends,
|
|
37
|
+
"report" => :cmd_report,
|
|
38
|
+
"compare" => :cmd_compare,
|
|
39
|
+
"baseline" => :cmd_baseline,
|
|
40
|
+
"sessions" => :cmd_sessions,
|
|
41
|
+
"history" => :cmd_history,
|
|
42
|
+
"validate" => :cmd_validate,
|
|
43
|
+
"suggest" => :cmd_suggest,
|
|
44
|
+
"ignore-suggest" => :cmd_ignore_suggest,
|
|
45
|
+
"pack" => :cmd_pack,
|
|
46
|
+
"export" => :cmd_export,
|
|
47
|
+
"probe" => :cmd_probe,
|
|
48
|
+
"measure" => :cmd_measure,
|
|
49
|
+
"retention" => :cmd_retention,
|
|
50
|
+
"findings" => :cmd_findings,
|
|
51
|
+
"scorecard" => :cmd_scorecard,
|
|
52
|
+
"table" => :cmd_table,
|
|
53
|
+
"explain" => :cmd_explain,
|
|
54
|
+
"open" => :cmd_open,
|
|
55
|
+
"html" => :cmd_html,
|
|
56
|
+
"self-test" => :cmd_self_test,
|
|
57
|
+
"env" => :cmd_env,
|
|
58
|
+
"completion" => :cmd_completion,
|
|
59
|
+
"man" => :cmd_man,
|
|
60
|
+
"codes" => :cmd_codes,
|
|
61
|
+
"doctor" => :cmd_doctor,
|
|
62
|
+
"overhead" => :cmd_overhead,
|
|
63
|
+
"config" => :cmd_config,
|
|
64
|
+
"about" => :cmd_about,
|
|
65
|
+
"capabilities" => :cmd_capabilities
|
|
66
|
+
}.freeze
|
|
67
|
+
|
|
68
|
+
def self.run(argv = ARGV)
|
|
69
|
+
new.run(argv)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def initialize
|
|
73
|
+
@json_mode = false
|
|
74
|
+
@color_enabled = Color.enabled?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def run(argv)
|
|
78
|
+
argv = argv.map(&:to_s)
|
|
79
|
+
parse_globals!(argv)
|
|
80
|
+
|
|
81
|
+
command = argv.shift
|
|
82
|
+
case command
|
|
83
|
+
when "version", "-v", "--version"
|
|
84
|
+
puts "heapscope #{VERSION}"
|
|
85
|
+
EXIT_OK
|
|
86
|
+
when "help", "-h", "--help", nil
|
|
87
|
+
topic = argv.shift
|
|
88
|
+
if topic && Help::COMMANDS.key?(topic)
|
|
89
|
+
puts Help.command_help(topic)
|
|
90
|
+
EXIT_OK
|
|
91
|
+
else
|
|
92
|
+
print_help
|
|
93
|
+
command.nil? ? EXIT_INVALID : EXIT_OK
|
|
94
|
+
end
|
|
95
|
+
else
|
|
96
|
+
method = COMMAND_MAP[command]
|
|
97
|
+
unless method
|
|
98
|
+
say_err Color.err("Unknown command: #{command}", enabled: color?)
|
|
99
|
+
print_help
|
|
100
|
+
return EXIT_INVALID
|
|
101
|
+
end
|
|
102
|
+
send(method, argv)
|
|
103
|
+
end
|
|
104
|
+
rescue CapabilityError => e
|
|
105
|
+
say_err "Capability unavailable: #{e.message}"
|
|
106
|
+
EXIT_CAPABILITY
|
|
107
|
+
rescue InvalidReportError, SnapshotError, ConfigurationError, ArgumentError => e
|
|
108
|
+
say_err "Invalid input: #{e.message}"
|
|
109
|
+
EXIT_INVALID
|
|
110
|
+
rescue BudgetExceededError => e
|
|
111
|
+
say_err e.message
|
|
112
|
+
EXIT_REGRESSION
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def parse_globals!(argv)
|
|
118
|
+
loop do
|
|
119
|
+
break if argv.empty?
|
|
120
|
+
|
|
121
|
+
case argv.first
|
|
122
|
+
when "--verbose"
|
|
123
|
+
argv.shift
|
|
124
|
+
HeapScope.config.verbose = true
|
|
125
|
+
when "--quiet"
|
|
126
|
+
argv.shift
|
|
127
|
+
HeapScope.config.quiet = true
|
|
128
|
+
when "--json"
|
|
129
|
+
argv.shift
|
|
130
|
+
@json_mode = true
|
|
131
|
+
when "--no-color"
|
|
132
|
+
argv.shift
|
|
133
|
+
@color_enabled = false
|
|
134
|
+
when "--color"
|
|
135
|
+
argv.shift
|
|
136
|
+
@color_enabled = true
|
|
137
|
+
when "--config"
|
|
138
|
+
argv.shift
|
|
139
|
+
path = argv.shift or raise ArgumentError, "--config requires PATH"
|
|
140
|
+
HeapScope.load_config!(path)
|
|
141
|
+
when /\A--config=(.+)\z/
|
|
142
|
+
argv.shift
|
|
143
|
+
HeapScope.load_config!(::Regexp.last_match(1))
|
|
144
|
+
else
|
|
145
|
+
break
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def print_help
|
|
151
|
+
puts Help.global_help
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def help_exit(command)
|
|
155
|
+
text = Help.command_help(command)
|
|
156
|
+
if text
|
|
157
|
+
puts text
|
|
158
|
+
EXIT_OK
|
|
159
|
+
else
|
|
160
|
+
print_help
|
|
161
|
+
EXIT_INVALID
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HeapScope
|
|
4
|
+
# Conservative Proc/closure retention analysis.
|
|
5
|
+
# Ruby does not expose captured bindings cleanly — treat results as hypotheses.
|
|
6
|
+
module Closures
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def inventory(limit: 200)
|
|
10
|
+
procs = []
|
|
11
|
+
return procs unless Runtime.current.each_object?
|
|
12
|
+
|
|
13
|
+
Runtime.current.each_object(Proc) do |proc|
|
|
14
|
+
break if procs.size >= limit
|
|
15
|
+
|
|
16
|
+
info = Runtime.current.allocation_info(proc)
|
|
17
|
+
retained = begin
|
|
18
|
+
Graph.new.estimate_retained_size(proc, max_objects: 500, max_depth: 4)
|
|
19
|
+
rescue StandardError
|
|
20
|
+
{ retained: nil }
|
|
21
|
+
end
|
|
22
|
+
procs << {
|
|
23
|
+
class: "Proc",
|
|
24
|
+
lambda: proc.lambda?,
|
|
25
|
+
arity: begin
|
|
26
|
+
proc.arity
|
|
27
|
+
rescue StandardError
|
|
28
|
+
nil
|
|
29
|
+
end,
|
|
30
|
+
allocation: info,
|
|
31
|
+
shallow_bytes: Runtime.current.memsize_of(proc),
|
|
32
|
+
approx_retained: retained[:retained],
|
|
33
|
+
approximate: true,
|
|
34
|
+
note: "Closure captures are not fully introspectable — retained size is approximate."
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
procs
|
|
38
|
+
rescue StandardError
|
|
39
|
+
[]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def findings(procs)
|
|
43
|
+
procs.select { |p| p[:approx_retained].to_i > 500_000 }.map do |p|
|
|
44
|
+
site = p.dig(:allocation, :file) && "#{p[:allocation][:file]}:#{p[:allocation][:line]}"
|
|
45
|
+
Finding.new(
|
|
46
|
+
code: "HS006",
|
|
47
|
+
severity: :medium,
|
|
48
|
+
subject: site || "Proc",
|
|
49
|
+
facts: [
|
|
50
|
+
"Observed fact: Proc approx retained #{p[:approx_retained]} bytes" \
|
|
51
|
+
"#{site ? " allocated at #{site}" : ""}."
|
|
52
|
+
],
|
|
53
|
+
derived: ["Derived: bounded retained-size estimate exceeded 500KB."],
|
|
54
|
+
hypothesis: "A long-lived Proc may be capturing a large object graph.",
|
|
55
|
+
suspected_cause: site,
|
|
56
|
+
suggestions: [
|
|
57
|
+
"Avoid capturing large objects in long-lived callbacks",
|
|
58
|
+
"Prefer weak refs or explicit clearable context objects"
|
|
59
|
+
],
|
|
60
|
+
evidence: [p]
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Fiber / execution-context awareness (best-effort).
|
|
67
|
+
module Fibers
|
|
68
|
+
module_function
|
|
69
|
+
|
|
70
|
+
def inventory
|
|
71
|
+
list = []
|
|
72
|
+
if defined?(Fiber) && Fiber.respond_to?(:list)
|
|
73
|
+
Fiber.list.each do |fiber|
|
|
74
|
+
list << fiber_info(fiber)
|
|
75
|
+
end
|
|
76
|
+
elsif defined?(Fiber)
|
|
77
|
+
list << fiber_info(Fiber.current)
|
|
78
|
+
end
|
|
79
|
+
list
|
|
80
|
+
rescue StandardError => e
|
|
81
|
+
[{ error: e.message }]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def fiber_info(fiber)
|
|
85
|
+
storage =
|
|
86
|
+
if fiber.respond_to?(:storage)
|
|
87
|
+
fiber.storage
|
|
88
|
+
else
|
|
89
|
+
{}
|
|
90
|
+
end
|
|
91
|
+
{
|
|
92
|
+
object_id: fiber.__id__,
|
|
93
|
+
alive: (fiber.alive? if fiber.respond_to?(:alive?)),
|
|
94
|
+
storage_keys: storage.respond_to?(:keys) ? storage.keys.map(&:inspect) : [],
|
|
95
|
+
storage_size: storage.respond_to?(:size) ? storage.size : nil,
|
|
96
|
+
note: "Fiber-local storage support varies by Ruby version."
|
|
97
|
+
}
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
{ error: e.message }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|