rperf 0.9.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
@@ -10,12 +10,102 @@ def find_available_port
10
10
  port
11
11
  end
12
12
 
13
- 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:)
14
89
  mode = :http
90
+ port = nil
91
+ host = nil
15
92
 
16
93
  parser = OptionParser.new do |opts|
17
94
  opts.banner = banner
18
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
+
19
109
  opts.on("--top", "Print top functions by #{min_files > 1 ? 'diff' : 'flat time'}") do
20
110
  mode = :top
21
111
  end
@@ -24,6 +114,12 @@ def run_pprof_subcommand(name, banner, min_files:)
24
114
  mode = :text
25
115
  end
26
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
+
27
123
  if min_files == 1
28
124
  opts.on("--html", "Output static HTML viewer to stdout") do
29
125
  mode = :html
@@ -38,27 +134,29 @@ def run_pprof_subcommand(name, banner, min_files:)
38
134
 
39
135
  begin
40
136
  parser.order!(ARGV)
41
- rescue OptionParser::InvalidOption, OptionParser::MissingArgument, OptionParser::NeedlessArgument => e
137
+ rescue OptionParser::ParseError => e
42
138
  $stderr.puts e.message
43
139
  $stderr.puts parser
44
140
  exit 1
45
141
  end
46
142
 
47
- if ARGV.size < min_files
48
- msg = min_files > 1 ? "Two profile files required." : "No profile file specified."
49
- $stderr.puts msg if min_files > 1
50
- # For report, use default file
51
- end
52
-
53
- files = ARGV.shift(min_files > 1 ? [ARGV.size, min_files].min : 1)
143
+ files = ARGV.shift(min_files)
54
144
  files = ["rperf.json.gz"] if files.empty? && min_files == 1
55
145
 
56
- if min_files > 1 && files.size < min_files
146
+ if files.size < min_files
57
147
  $stderr.puts "Two profile files required."
58
148
  $stderr.puts parser
59
149
  exit 1
60
150
  end
61
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
+
62
160
  files.each do |f|
63
161
  unless File.exist?(f)
64
162
  $stderr.puts "File not found: #{f}"
@@ -67,11 +165,11 @@ def run_pprof_subcommand(name, banner, min_files:)
67
165
  end
68
166
 
69
167
  # Go check is deferred — only needed for pprof files, not marshal/json
70
- yield mode, files
168
+ yield mode, files, port, host
71
169
  end
72
170
 
73
171
  def self.help_text
74
- @help_text ||= File.read(File.expand_path("../docs/help.md", __dir__))
172
+ File.read(File.expand_path("../docs/help.md", __dir__))
75
173
  end
76
174
 
77
175
  USAGE = "Usage: rperf record [options] command [args...]\n" \
@@ -101,15 +199,36 @@ when "help"
101
199
  puts help_text
102
200
  exit
103
201
  when "report"
104
- run_pprof_subcommand("report",
202
+ run_pprof_subcommand(
105
203
  "Usage: rperf report [options] [file]\n" \
106
204
  " Opens profile in browser (default) or prints summary.\n" \
107
205
  " Default file: rperf.json.gz\n" \
108
206
  " For .json.gz: opens rperf viewer (no Go required).\n" \
109
207
  " For .pb.gz: opens go tool pprof (requires Go).",
110
- min_files: 1) do |mode, files|
208
+ min_files: 1) do |mode, files, port, host|
111
209
  report_file = files[0]
112
- 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/
113
232
  # Use rperf viewer
114
233
  require_relative "../lib/rperf"
115
234
  require_relative "../lib/rperf/viewer"
@@ -123,49 +242,33 @@ when "report"
123
242
  when :text
124
243
  $stdout.puts Rperf::Text.encode(data)
125
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
126
251
  when :html
127
252
  $stdout.puts Rperf::Viewer.render_static_html(data)
128
253
  exit
129
254
  end
130
255
 
131
- port = find_available_port
132
-
133
256
  app = Rperf::Viewer.new(
134
257
  proc { |_| [404, { "content-type" => "text/plain" }, ["Not Found"]] },
135
258
  path: ""
136
259
  )
137
260
  app.add_snapshot(data)
138
-
139
- url = "http://localhost:#{port}/"
140
- $stderr.puts "Serving rperf viewer at #{url}"
141
- $stderr.puts "Press Ctrl-C to stop."
142
-
143
- # Open browser after server is ready
144
- Thread.new do
145
- 20.times do
146
- TCPSocket.new("localhost", port).close
147
- break
148
- rescue Errno::ECONNREFUSED
149
- sleep 0.1
150
- end
151
- case RUBY_PLATFORM
152
- when /darwin/ then system("open", url)
153
- when /linux/ then system("xdg-open", url, err: File::NULL) || system("sensible-browser", url, err: File::NULL)
154
- 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
155
267
  end
156
-
157
- # Find an available Rack handler
158
- begin
159
- require "rackup/handler"
160
- rescue LoadError
161
- $stderr.puts "Error: 'rackup' gem is required for 'rperf report'. Install it with:"
162
- $stderr.puts " gem install rackup"
268
+ if mode == :html
269
+ $stderr.puts "--html requires a .json.gz / .json profile (got: #{report_file})"
163
270
  exit 1
164
271
  end
165
- handler = Rackup::Handler.default
166
- handler.run(app, Port: port, Host: "localhost", Silent: true)
167
- exit
168
- else
169
272
  unless system("go", "version", out: File::NULL, err: File::NULL)
170
273
  $stderr.puts "'go' command not found. Install Go to use pprof files, or use .json.gz format."
171
274
  $stderr.puts " https://go.dev/dl/"
@@ -174,17 +277,38 @@ when "report"
174
277
  case mode
175
278
  when :top then exec("go", "tool", "pprof", "-top", report_file)
176
279
  when :text then exec("go", "tool", "pprof", "-text", report_file)
177
- 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)
178
285
  end
179
286
  end
180
287
  end
181
288
  when "diff"
182
- run_pprof_subcommand("diff",
289
+ run_pprof_subcommand(
183
290
  "Usage: rperf diff [options] base target\n" \
184
291
  " Compare two profiles (shows target - base).\n" \
185
292
  " Accepts .json.gz (auto-converted) or .pb.gz files.\n" \
186
- " Requires Go (https://go.dev/dl/).",
187
- 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
+
188
312
  unless system("go", "version", out: File::NULL, err: File::NULL)
189
313
  $stderr.puts "'go' command not found. Install Go to use 'rperf diff'."
190
314
  $stderr.puts " https://go.dev/dl/"
@@ -210,7 +334,10 @@ when "diff"
210
334
  pprof_args = case mode
211
335
  when :top then ["-top", "-diff_base=#{base_file}", target_file]
212
336
  when :text then ["-text", "-diff_base=#{base_file}", target_file]
213
- 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]
214
341
  end
215
342
  if tmpfiles.empty?
216
343
  exec("go", "tool", "pprof", *pprof_args)
@@ -230,6 +357,7 @@ else
230
357
  end
231
358
 
232
359
  output = (subcommand == "record") ? "rperf.json.gz" : nil
360
+ output_given = false
233
361
  frequency = 1000
234
362
  mode = (subcommand == "record") ? "cpu" : "wall"
235
363
  format = nil
@@ -238,6 +366,8 @@ verbose = false
238
366
  aggregate = true
239
367
  stat_report = (subcommand == "exec")
240
368
  inherit = true
369
+ meta_labels = {}
370
+ snapshot_dir = nil
241
371
 
242
372
  parser = OptionParser.new do |opts|
243
373
  opts.banner = case subcommand
@@ -248,6 +378,7 @@ parser = OptionParser.new do |opts|
248
378
 
249
379
  opts.on("-o", "--output PATH", "Output file#{subcommand == 'record' ? ' (default: rperf.json.gz)' : ' (default: none)'}") do |v|
250
380
  output = v
381
+ output_given = true
251
382
  end
252
383
 
253
384
  opts.on("-f", "--frequency HZ", Integer, "Sampling frequency in Hz (default: 1000)") do |v|
@@ -268,7 +399,21 @@ parser = OptionParser.new do |opts|
268
399
  opts.on("-p", "--print", "Print text profile to stdout (same as --format=text --output=/dev/stdout)") do
269
400
  format = "text"
270
401
  output = "/dev/stdout"
402
+ output_given = true
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
271
415
  end
416
+ meta_labels[key] = value
272
417
  end
273
418
 
274
419
  opts.on("--signal VALUE", "Timer signal (Linux only): signal number, or 'false' for nanosleep thread") do |v|
@@ -310,7 +455,7 @@ end
310
455
 
311
456
  begin
312
457
  parser.order!(ARGV)
313
- rescue OptionParser::InvalidOption, OptionParser::MissingArgument, OptionParser::NeedlessArgument => e
458
+ rescue OptionParser::ParseError => e
314
459
  $stderr.puts e.message
315
460
  $stderr.puts parser
316
461
  exit 1
@@ -345,6 +490,47 @@ if signal && signal != "false"
345
490
  end
346
491
  end
347
492
 
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
+
348
534
  # Add lib dir to RUBYLIB so -rrperf can find the correct version.
349
535
  # RUBYLIB handles spaces in paths safely (PATH_SEPARATOR delimited).
350
536
  # RUBYOPT -r<path> does not support spaces, so we use RUBYLIB + -rrperf.
@@ -367,8 +553,8 @@ if subcommand == "stat" || subcommand == "exec"
367
553
  end
368
554
 
369
555
  # Multi-process (fork) support: create a session directory for aggregation
556
+ session_dir = nil
370
557
  if inherit
371
- require_relative "../lib/rperf"
372
558
  session_dir = Rperf.send(:_create_session_dir, clean_stale: true)
373
559
  if session_dir
374
560
  ENV["RPERF_ROOT_PROCESS"] = Process.pid.to_s
@@ -376,4 +562,12 @@ if inherit
376
562
  end
377
563
  end
378
564
 
379
- exec(*ARGV)
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