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.
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
- def run_pprof_subcommand(name, banner, min_files:)
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::InvalidOption, OptionParser::MissingArgument => e
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
- if ARGV.size < min_files
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 min_files > 1 && files.size < min_files
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
- HELP_TEXT = File.read(File.expand_path("../docs/help.md", __dir__))
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.pb.gz target.pb.gz\n" \
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 HELP_TEXT
199
+ puts help_text
93
200
  exit
94
201
  when "report"
95
- run_pprof_subcommand("report",
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 =~ /\.json(\.gz)?\z/
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
- url = "http://localhost:#{port}/"
128
- $stderr.puts "Serving rperf viewer at #{url}"
129
- $stderr.puts "Press Ctrl-C to stop."
130
-
131
- # Open browser after server is ready
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
- # Find an available Rack handler
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 exec("go", "tool", "pprof", "-http=localhost:#{find_available_port}", report_file)
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("diff",
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 ["-http=localhost:#{find_available_port}", "-diff_base=#{base_file}", target_file]
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::InvalidOption, OptionParser::MissingArgument => e
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
- # Add lib dir to RUBYLIB so -rrperf can find the extension
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
- exec(*ARGV)
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