rperf 0.8.0 → 0.10.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 +4 -4
- data/LICENSE +21 -0
- data/README.md +26 -15
- data/docs/help.md +284 -18
- data/exe/rperf +278 -55
- data/ext/rperf/rperf.c +220 -81
- data/lib/rperf/active_job.rb +1 -0
- data/lib/rperf/meta.rb +343 -0
- data/lib/rperf/rack.rb +7 -2
- data/lib/rperf/table.rb +156 -0
- data/lib/rperf/version.rb +1 -1
- data/lib/rperf/viewer/viewer.html +1148 -0
- data/lib/rperf/viewer.rb +158 -661
- data/lib/rperf.rb +682 -89
- metadata +8 -4
data/exe/rperf
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
require "optparse"
|
|
3
3
|
require "socket"
|
|
4
|
+
require "fileutils"
|
|
4
5
|
|
|
5
6
|
def find_available_port
|
|
6
7
|
server = TCPServer.new("localhost", 0)
|
|
@@ -9,12 +10,102 @@ def find_available_port
|
|
|
9
10
|
port
|
|
10
11
|
end
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
# Whether a GUI browser can be opened. Without a display, xdg-open /
|
|
14
|
+
# sensible-browser fall back to terminal browsers (w3m etc.), which take
|
|
15
|
+
# over the TTY and cannot run the viewer's JavaScript anyway.
|
|
16
|
+
def can_open_browser?
|
|
17
|
+
case RUBY_PLATFORM
|
|
18
|
+
when /darwin/ then true
|
|
19
|
+
when /linux/ then !!(ENV["DISPLAY"] || ENV["WAYLAND_DISPLAY"])
|
|
20
|
+
else false
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Serve a Rperf::Viewer app and open the browser (only with a GUI and a
|
|
25
|
+
# local bind). Does not return (runs the Rack handler until interrupted).
|
|
26
|
+
def serve_viewer(app, port: nil, host: nil)
|
|
27
|
+
# Find an available Rack handler first, before claiming to serve
|
|
28
|
+
begin
|
|
29
|
+
require "rackup/handler"
|
|
30
|
+
rescue LoadError
|
|
31
|
+
$stderr.puts "Error: 'rackup' gem is required for 'rperf report'. Install it with:"
|
|
32
|
+
$stderr.puts " gem install rackup"
|
|
33
|
+
exit 1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if port
|
|
37
|
+
# Fail fast on a taken port — otherwise the readiness probe below would
|
|
38
|
+
# "succeed" against the foreign server and open the browser on it, and
|
|
39
|
+
# the handler would die with a raw EADDRINUSE backtrace
|
|
40
|
+
begin
|
|
41
|
+
TCPServer.open(host || "localhost", port) {}
|
|
42
|
+
rescue Errno::EADDRINUSE
|
|
43
|
+
$stderr.puts "Error: port #{port} is already in use (choose another --port)"
|
|
44
|
+
exit 1
|
|
45
|
+
rescue SystemCallError
|
|
46
|
+
# unbindable host etc. — let the handler report it
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
port ||= find_available_port
|
|
50
|
+
host ||= "localhost"
|
|
51
|
+
local = %w[localhost 127.0.0.1 ::1].include?(host)
|
|
52
|
+
# 0.0.0.0 binds all interfaces; show a URL that works from outside
|
|
53
|
+
display_host = (host == "0.0.0.0") ? Socket.gethostname : host
|
|
54
|
+
url = "http://#{display_host}:#{port}/"
|
|
55
|
+
$stderr.puts "Serving rperf viewer at #{url} (bound to #{host})"
|
|
56
|
+
unless local
|
|
57
|
+
$stderr.puts "WARNING: the viewer has no authentication; anyone who can reach #{host}:#{port} can read the profile."
|
|
58
|
+
end
|
|
59
|
+
auto_open = local && can_open_browser?
|
|
60
|
+
$stderr.puts "Open the URL in a browser (not auto-opening)." unless auto_open
|
|
61
|
+
$stderr.puts "Press Ctrl-C to stop."
|
|
62
|
+
|
|
63
|
+
# Open browser after server is ready
|
|
64
|
+
if auto_open
|
|
65
|
+
Thread.new do
|
|
66
|
+
20.times do
|
|
67
|
+
TCPSocket.new(host, port).close
|
|
68
|
+
break
|
|
69
|
+
rescue Errno::ECONNREFUSED
|
|
70
|
+
sleep 0.1
|
|
71
|
+
end
|
|
72
|
+
case RUBY_PLATFORM
|
|
73
|
+
when /darwin/ then system("open", url)
|
|
74
|
+
when /linux/ then system("xdg-open", url, out: File::NULL, err: File::NULL) || system("sensible-browser", url, out: File::NULL, err: File::NULL)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
handler = Rackup::Handler.default
|
|
80
|
+
begin
|
|
81
|
+
handler.run(app, Port: port, Host: host, Silent: true)
|
|
82
|
+
rescue Errno::EADDRINUSE
|
|
83
|
+
$stderr.puts "Error: port #{port} is already in use (choose another --port)"
|
|
84
|
+
exit 1
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def run_pprof_subcommand(banner, min_files:)
|
|
13
89
|
mode = :http
|
|
90
|
+
port = nil
|
|
91
|
+
host = nil
|
|
14
92
|
|
|
15
93
|
parser = OptionParser.new do |opts|
|
|
16
94
|
opts.banner = banner
|
|
17
95
|
|
|
96
|
+
opts.on("--port PORT", Integer, "Port for the web UI (default: auto). Useful for SSH port forwarding") do |v|
|
|
97
|
+
unless v.between?(1, 65535)
|
|
98
|
+
$stderr.puts "Error: --port must be 1..65535, got #{v}"
|
|
99
|
+
exit 1
|
|
100
|
+
end
|
|
101
|
+
port = v
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
opts.on("--host HOST", "Bind address for the web UI (default: localhost). " \
|
|
105
|
+
"Use 0.0.0.0 to allow external access — the viewer has NO authentication") do |v|
|
|
106
|
+
host = v
|
|
107
|
+
end
|
|
108
|
+
|
|
18
109
|
opts.on("--top", "Print top functions by #{min_files > 1 ? 'diff' : 'flat time'}") do
|
|
19
110
|
mode = :top
|
|
20
111
|
end
|
|
@@ -23,6 +114,18 @@ def run_pprof_subcommand(name, banner, min_files:)
|
|
|
23
114
|
mode = :text
|
|
24
115
|
end
|
|
25
116
|
|
|
117
|
+
opts.on("--format FORMAT", %w[table table-json],
|
|
118
|
+
"Flat #{min_files > 1 ? 'diff ' : ''}table for AI/machine consumption: " \
|
|
119
|
+
"'table' (TSV) or 'table-json' (JSON)") do |v|
|
|
120
|
+
mode = (v == "table") ? :table : :table_json
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
if min_files == 1
|
|
124
|
+
opts.on("--html", "Output static HTML viewer to stdout") do
|
|
125
|
+
mode = :html
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
26
129
|
opts.on("-h", "--help", "Show this help") do
|
|
27
130
|
puts opts
|
|
28
131
|
exit
|
|
@@ -31,27 +134,29 @@ def run_pprof_subcommand(name, banner, min_files:)
|
|
|
31
134
|
|
|
32
135
|
begin
|
|
33
136
|
parser.order!(ARGV)
|
|
34
|
-
rescue OptionParser::
|
|
137
|
+
rescue OptionParser::ParseError => e
|
|
35
138
|
$stderr.puts e.message
|
|
36
139
|
$stderr.puts parser
|
|
37
140
|
exit 1
|
|
38
141
|
end
|
|
39
142
|
|
|
40
|
-
|
|
41
|
-
msg = min_files > 1 ? "Two profile files required." : "No profile file specified."
|
|
42
|
-
$stderr.puts msg if min_files > 1
|
|
43
|
-
# For report, use default file
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
files = ARGV.shift(min_files > 1 ? [ARGV.size, min_files].min : 1)
|
|
143
|
+
files = ARGV.shift(min_files)
|
|
47
144
|
files = ["rperf.json.gz"] if files.empty? && min_files == 1
|
|
48
145
|
|
|
49
|
-
if
|
|
146
|
+
if files.size < min_files
|
|
50
147
|
$stderr.puts "Two profile files required."
|
|
51
148
|
$stderr.puts parser
|
|
52
149
|
exit 1
|
|
53
150
|
end
|
|
54
151
|
|
|
152
|
+
# order! stops at the first non-option — anything left over (e.g. options
|
|
153
|
+
# placed after the file argument) would be silently ignored
|
|
154
|
+
unless ARGV.empty?
|
|
155
|
+
$stderr.puts "Unexpected extra arguments: #{ARGV.join(' ')} (options must come before the file arguments)"
|
|
156
|
+
$stderr.puts parser
|
|
157
|
+
exit 1
|
|
158
|
+
end
|
|
159
|
+
|
|
55
160
|
files.each do |f|
|
|
56
161
|
unless File.exist?(f)
|
|
57
162
|
$stderr.puts "File not found: #{f}"
|
|
@@ -60,16 +165,18 @@ def run_pprof_subcommand(name, banner, min_files:)
|
|
|
60
165
|
end
|
|
61
166
|
|
|
62
167
|
# Go check is deferred — only needed for pprof files, not marshal/json
|
|
63
|
-
yield mode, files
|
|
168
|
+
yield mode, files, port, host
|
|
64
169
|
end
|
|
65
170
|
|
|
66
|
-
|
|
171
|
+
def self.help_text
|
|
172
|
+
File.read(File.expand_path("../docs/help.md", __dir__))
|
|
173
|
+
end
|
|
67
174
|
|
|
68
175
|
USAGE = "Usage: rperf record [options] command [args...]\n" \
|
|
69
176
|
" rperf stat [options] command [args...]\n" \
|
|
70
177
|
" rperf exec [options] command [args...]\n" \
|
|
71
178
|
" rperf report [options] [file]\n" \
|
|
72
|
-
" rperf diff [options] base
|
|
179
|
+
" rperf diff [options] base target\n" \
|
|
73
180
|
" rperf help\n"
|
|
74
181
|
|
|
75
182
|
# Handle top-level flags before subcommand parsing
|
|
@@ -89,18 +196,39 @@ subcommand = ARGV.shift
|
|
|
89
196
|
|
|
90
197
|
case subcommand
|
|
91
198
|
when "help"
|
|
92
|
-
puts
|
|
199
|
+
puts help_text
|
|
93
200
|
exit
|
|
94
201
|
when "report"
|
|
95
|
-
run_pprof_subcommand(
|
|
202
|
+
run_pprof_subcommand(
|
|
96
203
|
"Usage: rperf report [options] [file]\n" \
|
|
97
204
|
" Opens profile in browser (default) or prints summary.\n" \
|
|
98
205
|
" Default file: rperf.json.gz\n" \
|
|
99
206
|
" For .json.gz: opens rperf viewer (no Go required).\n" \
|
|
100
207
|
" For .pb.gz: opens go tool pprof (requires Go).",
|
|
101
|
-
min_files: 1) do |mode, files|
|
|
208
|
+
min_files: 1) do |mode, files, port, host|
|
|
102
209
|
report_file = files[0]
|
|
103
|
-
if report_file
|
|
210
|
+
if File.directory?(report_file)
|
|
211
|
+
# Time-travel mode: list all profiles in the directory (meta/summary
|
|
212
|
+
# only — bodies are lazy-loaded on selection)
|
|
213
|
+
unless mode == :http
|
|
214
|
+
$stderr.puts "Directory input opens the time-travel viewer; --top/--text/--html/--format are not supported"
|
|
215
|
+
exit 1
|
|
216
|
+
end
|
|
217
|
+
require_relative "../lib/rperf"
|
|
218
|
+
require_relative "../lib/rperf/viewer"
|
|
219
|
+
app = Rperf::Viewer.new(
|
|
220
|
+
proc { |_| [404, { "content-type" => "text/plain" }, ["Not Found"]] },
|
|
221
|
+
path: ""
|
|
222
|
+
)
|
|
223
|
+
count = app.add_snapshot_dir(report_file)
|
|
224
|
+
if count == 0
|
|
225
|
+
$stderr.puts "No .json(.gz) profiles found in #{report_file}"
|
|
226
|
+
exit 1
|
|
227
|
+
end
|
|
228
|
+
$stderr.puts "Loaded #{count} snapshot#{count == 1 ? '' : 's'} from #{report_file}"
|
|
229
|
+
serve_viewer(app, port: port, host: host)
|
|
230
|
+
exit
|
|
231
|
+
elsif report_file =~ /\.json(\.gz)?\z/
|
|
104
232
|
# Use rperf viewer
|
|
105
233
|
require_relative "../lib/rperf"
|
|
106
234
|
require_relative "../lib/rperf/viewer"
|
|
@@ -114,46 +242,33 @@ when "report"
|
|
|
114
242
|
when :text
|
|
115
243
|
$stdout.puts Rperf::Text.encode(data)
|
|
116
244
|
exit
|
|
245
|
+
when :table
|
|
246
|
+
$stdout.print Rperf::Table.report_tsv(data)
|
|
247
|
+
exit
|
|
248
|
+
when :table_json
|
|
249
|
+
$stdout.puts Rperf::Table.report_json(data)
|
|
250
|
+
exit
|
|
251
|
+
when :html
|
|
252
|
+
$stdout.puts Rperf::Viewer.render_static_html(data)
|
|
253
|
+
exit
|
|
117
254
|
end
|
|
118
255
|
|
|
119
|
-
port = find_available_port
|
|
120
|
-
|
|
121
256
|
app = Rperf::Viewer.new(
|
|
122
257
|
proc { |_| [404, { "content-type" => "text/plain" }, ["Not Found"]] },
|
|
123
258
|
path: ""
|
|
124
259
|
)
|
|
125
260
|
app.add_snapshot(data)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
Thread.new do
|
|
133
|
-
20.times do
|
|
134
|
-
TCPSocket.new("localhost", port).close
|
|
135
|
-
break
|
|
136
|
-
rescue Errno::ECONNREFUSED
|
|
137
|
-
sleep 0.1
|
|
138
|
-
end
|
|
139
|
-
case RUBY_PLATFORM
|
|
140
|
-
when /darwin/ then system("open", url)
|
|
141
|
-
when /linux/ then system("xdg-open", url, err: File::NULL) || system("sensible-browser", url, err: File::NULL)
|
|
142
|
-
end
|
|
261
|
+
serve_viewer(app, port: port, host: host)
|
|
262
|
+
exit
|
|
263
|
+
else
|
|
264
|
+
if mode == :table || mode == :table_json
|
|
265
|
+
$stderr.puts "--format table requires a .json.gz / .json profile (got: #{report_file})"
|
|
266
|
+
exit 1
|
|
143
267
|
end
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
begin
|
|
147
|
-
require "rackup/handler"
|
|
148
|
-
rescue LoadError
|
|
149
|
-
$stderr.puts "Error: 'rackup' gem is required for 'rperf report'. Install it with:"
|
|
150
|
-
$stderr.puts " gem install rackup"
|
|
268
|
+
if mode == :html
|
|
269
|
+
$stderr.puts "--html requires a .json.gz / .json profile (got: #{report_file})"
|
|
151
270
|
exit 1
|
|
152
271
|
end
|
|
153
|
-
handler = Rackup::Handler.default
|
|
154
|
-
handler.run(app, Port: port, Host: "localhost", Silent: true)
|
|
155
|
-
exit
|
|
156
|
-
else
|
|
157
272
|
unless system("go", "version", out: File::NULL, err: File::NULL)
|
|
158
273
|
$stderr.puts "'go' command not found. Install Go to use pprof files, or use .json.gz format."
|
|
159
274
|
$stderr.puts " https://go.dev/dl/"
|
|
@@ -162,17 +277,38 @@ when "report"
|
|
|
162
277
|
case mode
|
|
163
278
|
when :top then exec("go", "tool", "pprof", "-top", report_file)
|
|
164
279
|
when :text then exec("go", "tool", "pprof", "-text", report_file)
|
|
165
|
-
else
|
|
280
|
+
else
|
|
281
|
+
# pprof has its own browser launcher — suppress it without a GUI
|
|
282
|
+
http_args = ["-http=#{host || %q(localhost)}:#{port || find_available_port}"]
|
|
283
|
+
http_args << "-no_browser" unless can_open_browser?
|
|
284
|
+
exec("go", "tool", "pprof", *http_args, report_file)
|
|
166
285
|
end
|
|
167
286
|
end
|
|
168
287
|
end
|
|
169
288
|
when "diff"
|
|
170
|
-
run_pprof_subcommand(
|
|
289
|
+
run_pprof_subcommand(
|
|
171
290
|
"Usage: rperf diff [options] base target\n" \
|
|
172
291
|
" Compare two profiles (shows target - base).\n" \
|
|
173
292
|
" Accepts .json.gz (auto-converted) or .pb.gz files.\n" \
|
|
174
|
-
" Requires Go (https://go.dev/dl/).",
|
|
175
|
-
min_files: 2) do |mode, files|
|
|
293
|
+
" Requires Go (https://go.dev/dl/), except --format table/table-json.",
|
|
294
|
+
min_files: 2) do |mode, files, port, host|
|
|
295
|
+
# Table diff is computed in Ruby — no Go required
|
|
296
|
+
if mode == :table || mode == :table_json
|
|
297
|
+
unless files.all? { |f| f =~ /\.json(\.gz)?\z/ }
|
|
298
|
+
$stderr.puts "--format table requires .json.gz / .json profiles"
|
|
299
|
+
exit 1
|
|
300
|
+
end
|
|
301
|
+
require_relative "../lib/rperf"
|
|
302
|
+
base = Rperf.load(files[0])
|
|
303
|
+
head = Rperf.load(files[1])
|
|
304
|
+
if mode == :table
|
|
305
|
+
$stdout.print Rperf::Table.diff_tsv(base, head)
|
|
306
|
+
else
|
|
307
|
+
$stdout.puts Rperf::Table.diff_json(base, head)
|
|
308
|
+
end
|
|
309
|
+
exit
|
|
310
|
+
end
|
|
311
|
+
|
|
176
312
|
unless system("go", "version", out: File::NULL, err: File::NULL)
|
|
177
313
|
$stderr.puts "'go' command not found. Install Go to use 'rperf diff'."
|
|
178
314
|
$stderr.puts " https://go.dev/dl/"
|
|
@@ -198,7 +334,10 @@ when "diff"
|
|
|
198
334
|
pprof_args = case mode
|
|
199
335
|
when :top then ["-top", "-diff_base=#{base_file}", target_file]
|
|
200
336
|
when :text then ["-text", "-diff_base=#{base_file}", target_file]
|
|
201
|
-
else
|
|
337
|
+
else
|
|
338
|
+
args = ["-http=#{host || %q(localhost)}:#{port || find_available_port}"]
|
|
339
|
+
args << "-no_browser" unless can_open_browser?
|
|
340
|
+
args + ["-diff_base=#{base_file}", target_file]
|
|
202
341
|
end
|
|
203
342
|
if tmpfiles.empty?
|
|
204
343
|
exec("go", "tool", "pprof", *pprof_args)
|
|
@@ -218,6 +357,7 @@ else
|
|
|
218
357
|
end
|
|
219
358
|
|
|
220
359
|
output = (subcommand == "record") ? "rperf.json.gz" : nil
|
|
360
|
+
output_given = false
|
|
221
361
|
frequency = 1000
|
|
222
362
|
mode = (subcommand == "record") ? "cpu" : "wall"
|
|
223
363
|
format = nil
|
|
@@ -225,6 +365,9 @@ signal = nil
|
|
|
225
365
|
verbose = false
|
|
226
366
|
aggregate = true
|
|
227
367
|
stat_report = (subcommand == "exec")
|
|
368
|
+
inherit = true
|
|
369
|
+
meta_labels = {}
|
|
370
|
+
snapshot_dir = nil
|
|
228
371
|
|
|
229
372
|
parser = OptionParser.new do |opts|
|
|
230
373
|
opts.banner = case subcommand
|
|
@@ -235,6 +378,7 @@ parser = OptionParser.new do |opts|
|
|
|
235
378
|
|
|
236
379
|
opts.on("-o", "--output PATH", "Output file#{subcommand == 'record' ? ' (default: rperf.json.gz)' : ' (default: none)'}") do |v|
|
|
237
380
|
output = v
|
|
381
|
+
output_given = true
|
|
238
382
|
end
|
|
239
383
|
|
|
240
384
|
opts.on("-f", "--frequency HZ", Integer, "Sampling frequency in Hz (default: 1000)") do |v|
|
|
@@ -255,7 +399,21 @@ parser = OptionParser.new do |opts|
|
|
|
255
399
|
opts.on("-p", "--print", "Print text profile to stdout (same as --format=text --output=/dev/stdout)") do
|
|
256
400
|
format = "text"
|
|
257
401
|
output = "/dev/stdout"
|
|
402
|
+
output_given = true
|
|
258
403
|
end
|
|
404
|
+
|
|
405
|
+
opts.on("--snapshot-dir DIR", "Save output as rperf-<sha7>-<timestamp>.json.gz in DIR") do |v|
|
|
406
|
+
snapshot_dir = v
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
opts.on("--label KEY=VALUE", "Add a label to profile metadata (repeatable)") do |v|
|
|
411
|
+
key, value = v.split("=", 2)
|
|
412
|
+
if key.nil? || key.empty? || value.nil?
|
|
413
|
+
$stderr.puts "Error: --label must be KEY=VALUE, got: #{v.inspect}"
|
|
414
|
+
exit 1
|
|
415
|
+
end
|
|
416
|
+
meta_labels[key] = value
|
|
259
417
|
end
|
|
260
418
|
|
|
261
419
|
opts.on("--signal VALUE", "Timer signal (Linux only): signal number, or 'false' for nanosleep thread") do |v|
|
|
@@ -279,6 +437,10 @@ parser = OptionParser.new do |opts|
|
|
|
279
437
|
end
|
|
280
438
|
end
|
|
281
439
|
|
|
440
|
+
opts.on("--no-inherit", "Do not profile forked/spawned child processes (default: inherit)") do
|
|
441
|
+
inherit = false
|
|
442
|
+
end
|
|
443
|
+
|
|
282
444
|
opts.on("-v", "--verbose", "Print sampling statistics to stderr") do
|
|
283
445
|
verbose = true
|
|
284
446
|
end
|
|
@@ -293,7 +455,7 @@ end
|
|
|
293
455
|
|
|
294
456
|
begin
|
|
295
457
|
parser.order!(ARGV)
|
|
296
|
-
rescue OptionParser::
|
|
458
|
+
rescue OptionParser::ParseError => e
|
|
297
459
|
$stderr.puts e.message
|
|
298
460
|
$stderr.puts parser
|
|
299
461
|
exit 1
|
|
@@ -328,7 +490,50 @@ if signal && signal != "false"
|
|
|
328
490
|
end
|
|
329
491
|
end
|
|
330
492
|
|
|
331
|
-
#
|
|
493
|
+
# Load the library BEFORE exporting RPERF_ENABLED — requiring it afterwards
|
|
494
|
+
# would trigger the ENV-based auto-start in the CLI process itself.
|
|
495
|
+
require_relative "../lib/rperf"
|
|
496
|
+
require "json"
|
|
497
|
+
|
|
498
|
+
# Collect git metadata here, not in the profiled process: the app may chdir,
|
|
499
|
+
# which would point git at the wrong repository. The profiled process reads
|
|
500
|
+
# RPERF_META_GIT instead of running git ("null" = checked, not a repo).
|
|
501
|
+
git_info = Rperf::Meta.collect_git
|
|
502
|
+
ENV["RPERF_META_GIT"] = git_info ? JSON.generate(git_info) : "null"
|
|
503
|
+
ENV["RPERF_META_LABELS"] = JSON.generate(meta_labels) unless meta_labels.empty?
|
|
504
|
+
|
|
505
|
+
if snapshot_dir
|
|
506
|
+
if output_given
|
|
507
|
+
$stderr.puts "Error: --snapshot-dir and -o/--output (or -p) are mutually exclusive"
|
|
508
|
+
exit 1
|
|
509
|
+
end
|
|
510
|
+
if format && format != "json"
|
|
511
|
+
$stderr.puts "Error: --snapshot-dir requires JSON format (got --format=#{format})"
|
|
512
|
+
exit 1
|
|
513
|
+
end
|
|
514
|
+
FileUtils.mkdir_p(snapshot_dir)
|
|
515
|
+
output = File.join(snapshot_dir, Rperf::Meta.snapshot_filename(git_info))
|
|
516
|
+
# Timestamp resolution is one second and the in-git name has no pid —
|
|
517
|
+
# don't overwrite an earlier run from the same second on the same commit
|
|
518
|
+
output = output.sub(/\.json\.gz\z/, "-#{Process.pid}.json.gz") if File.exist?(output)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
if output
|
|
522
|
+
# Resolve now: the profiled app may chdir, which would silently relocate a
|
|
523
|
+
# relative output path. Validate writability up front, too — discovering an
|
|
524
|
+
# unwritable path in at_exit wastes the entire profiling run.
|
|
525
|
+
output = File.expand_path(output)
|
|
526
|
+
out_dir = File.dirname(output)
|
|
527
|
+
writable = File.exist?(output) ? File.writable?(output) : (File.directory?(out_dir) && File.writable?(out_dir))
|
|
528
|
+
unless writable
|
|
529
|
+
$stderr.puts "Error: output path is not writable: #{output}"
|
|
530
|
+
exit 1
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# Add lib dir to RUBYLIB so -rrperf can find the correct version.
|
|
535
|
+
# RUBYLIB handles spaces in paths safely (PATH_SEPARATOR delimited).
|
|
536
|
+
# RUBYOPT -r<path> does not support spaces, so we use RUBYLIB + -rrperf.
|
|
332
537
|
lib_dir = File.expand_path("../lib", __dir__)
|
|
333
538
|
ENV["RUBYLIB"] = [lib_dir, ENV["RUBYLIB"]].compact.join(File::PATH_SEPARATOR)
|
|
334
539
|
ENV["RUBYOPT"] = "-rrperf #{ENV['RUBYOPT']}".strip
|
|
@@ -347,4 +552,22 @@ if subcommand == "stat" || subcommand == "exec"
|
|
|
347
552
|
ENV["RPERF_STAT_REPORT"] = "1" if stat_report
|
|
348
553
|
end
|
|
349
554
|
|
|
350
|
-
|
|
555
|
+
# Multi-process (fork) support: create a session directory for aggregation
|
|
556
|
+
session_dir = nil
|
|
557
|
+
if inherit
|
|
558
|
+
session_dir = Rperf.send(:_create_session_dir, clean_stale: true)
|
|
559
|
+
if session_dir
|
|
560
|
+
ENV["RPERF_ROOT_PROCESS"] = Process.pid.to_s
|
|
561
|
+
ENV["RPERF_SESSION_DIR"] = session_dir
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
begin
|
|
566
|
+
exec(*ARGV)
|
|
567
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
568
|
+
$stderr.puts "rperf: failed to execute #{ARGV.first.inspect}: #{e.message}"
|
|
569
|
+
# exec never ran — remove the eagerly created session dir instead of
|
|
570
|
+
# leaving it for the stale-PID sweep
|
|
571
|
+
FileUtils.rm_rf(session_dir) if session_dir
|
|
572
|
+
exit(e.is_a?(Errno::ENOENT) ? 127 : 126)
|
|
573
|
+
end
|