rperf 0.9.0 → 0.11.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,9 @@ verbose = false
238
366
  aggregate = true
239
367
  stat_report = (subcommand == "exec")
240
368
  inherit = true
369
+ meta_labels = {}
370
+ snapshot_dir = nil
371
+ print_env = false
241
372
 
242
373
  parser = OptionParser.new do |opts|
243
374
  opts.banner = case subcommand
@@ -248,6 +379,7 @@ parser = OptionParser.new do |opts|
248
379
 
249
380
  opts.on("-o", "--output PATH", "Output file#{subcommand == 'record' ? ' (default: rperf.json.gz)' : ' (default: none)'}") do |v|
250
381
  output = v
382
+ output_given = true
251
383
  end
252
384
 
253
385
  opts.on("-f", "--frequency HZ", Integer, "Sampling frequency in Hz (default: 1000)") do |v|
@@ -268,9 +400,23 @@ parser = OptionParser.new do |opts|
268
400
  opts.on("-p", "--print", "Print text profile to stdout (same as --format=text --output=/dev/stdout)") do
269
401
  format = "text"
270
402
  output = "/dev/stdout"
403
+ output_given = true
404
+ end
405
+
406
+ opts.on("--snapshot-dir DIR", "Save output as rperf-<sha7>-<timestamp>.json.gz in DIR") do |v|
407
+ snapshot_dir = v
271
408
  end
272
409
  end
273
410
 
411
+ opts.on("--label KEY=VALUE", "Add a label to profile metadata (repeatable)") do |v|
412
+ key, value = v.split("=", 2)
413
+ if key.nil? || key.empty? || value.nil?
414
+ $stderr.puts "Error: --label must be KEY=VALUE, got: #{v.inspect}"
415
+ exit 1
416
+ end
417
+ meta_labels[key] = value
418
+ end
419
+
274
420
  opts.on("--signal VALUE", "Timer signal (Linux only): signal number, or 'false' for nanosleep thread") do |v|
275
421
  if v == "false"
276
422
  signal = "false"
@@ -296,6 +442,13 @@ parser = OptionParser.new do |opts|
296
442
  inherit = false
297
443
  end
298
444
 
445
+ opts.on("--print-env",
446
+ "Print the environment rperf would set (one `export KEY=VALUE` per line), then exit " \
447
+ "WITHOUT running a command. The caller sources it, sets RPERF_ROOT_PROCESS to the pid " \
448
+ "that will exec the command, and runs the command itself — so rperf never wraps it.") do
449
+ print_env = true
450
+ end
451
+
299
452
  opts.on("-v", "--verbose", "Print sampling statistics to stderr") do
300
453
  verbose = true
301
454
  end
@@ -310,13 +463,13 @@ end
310
463
 
311
464
  begin
312
465
  parser.order!(ARGV)
313
- rescue OptionParser::InvalidOption, OptionParser::MissingArgument, OptionParser::NeedlessArgument => e
466
+ rescue OptionParser::ParseError => e
314
467
  $stderr.puts e.message
315
468
  $stderr.puts parser
316
469
  exit 1
317
470
  end
318
471
 
319
- if ARGV.empty?
472
+ if ARGV.empty? && !print_env
320
473
  $stderr.puts "No command specified."
321
474
  $stderr.puts parser
322
475
  exit 1
@@ -345,6 +498,47 @@ if signal && signal != "false"
345
498
  end
346
499
  end
347
500
 
501
+ # Load the library BEFORE exporting RPERF_ENABLED — requiring it afterwards
502
+ # would trigger the ENV-based auto-start in the CLI process itself.
503
+ require_relative "../lib/rperf"
504
+ require "json"
505
+
506
+ # Collect git metadata here, not in the profiled process: the app may chdir,
507
+ # which would point git at the wrong repository. The profiled process reads
508
+ # RPERF_META_GIT instead of running git ("null" = checked, not a repo).
509
+ git_info = Rperf::Meta.collect_git
510
+ ENV["RPERF_META_GIT"] = git_info ? JSON.generate(git_info) : "null"
511
+ ENV["RPERF_META_LABELS"] = JSON.generate(meta_labels) unless meta_labels.empty?
512
+
513
+ if snapshot_dir
514
+ if output_given
515
+ $stderr.puts "Error: --snapshot-dir and -o/--output (or -p) are mutually exclusive"
516
+ exit 1
517
+ end
518
+ if format && format != "json"
519
+ $stderr.puts "Error: --snapshot-dir requires JSON format (got --format=#{format})"
520
+ exit 1
521
+ end
522
+ FileUtils.mkdir_p(snapshot_dir)
523
+ output = File.join(snapshot_dir, Rperf::Meta.snapshot_filename(git_info))
524
+ # Timestamp resolution is one second and the in-git name has no pid —
525
+ # don't overwrite an earlier run from the same second on the same commit
526
+ output = output.sub(/\.json\.gz\z/, "-#{Process.pid}.json.gz") if File.exist?(output)
527
+ end
528
+
529
+ if output
530
+ # Resolve now: the profiled app may chdir, which would silently relocate a
531
+ # relative output path. Validate writability up front, too — discovering an
532
+ # unwritable path in at_exit wastes the entire profiling run.
533
+ output = File.expand_path(output)
534
+ out_dir = File.dirname(output)
535
+ writable = File.exist?(output) ? File.writable?(output) : (File.directory?(out_dir) && File.writable?(out_dir))
536
+ unless writable
537
+ $stderr.puts "Error: output path is not writable: #{output}"
538
+ exit 1
539
+ end
540
+ end
541
+
348
542
  # Add lib dir to RUBYLIB so -rrperf can find the correct version.
349
543
  # RUBYLIB handles spaces in paths safely (PATH_SEPARATOR delimited).
350
544
  # RUBYOPT -r<path> does not support spaces, so we use RUBYLIB + -rrperf.
@@ -367,13 +561,39 @@ if subcommand == "stat" || subcommand == "exec"
367
561
  end
368
562
 
369
563
  # Multi-process (fork) support: create a session directory for aggregation
564
+ session_dir = nil
370
565
  if inherit
371
- require_relative "../lib/rperf"
372
566
  session_dir = Rperf.send(:_create_session_dir, clean_stale: true)
373
567
  if session_dir
374
- ENV["RPERF_ROOT_PROCESS"] = Process.pid.to_s
375
568
  ENV["RPERF_SESSION_DIR"] = session_dir
569
+ # In --print-env mode this short-lived process does not run the command, so
570
+ # it must not claim rootship: the caller sets RPERF_ROOT_PROCESS to the pid
571
+ # that will exec the command. Otherwise (normal exec) we are the root.
572
+ ENV["RPERF_ROOT_PROCESS"] = Process.pid.to_s unless print_env
376
573
  end
377
574
  end
378
575
 
379
- exec(*ARGV)
576
+ # --print-env: emit the environment a profiled process inherits to auto-start
577
+ # rperf, then exit without running anything. RPERF_ROOT_PROCESS and
578
+ # RPERF_META_LABELS are deliberately omitted — the caller owns those (it sets
579
+ # the root pid, and labels per measurement run).
580
+ if print_env
581
+ require "shellwords"
582
+ %w[RUBYLIB RUBYOPT RPERF_ENABLED RPERF_OUTPUT RPERF_FREQUENCY RPERF_MODE
583
+ RPERF_FORMAT RPERF_VERBOSE RPERF_SIGNAL RPERF_AGGREGATE RPERF_META_GIT
584
+ RPERF_SESSION_DIR RPERF_STAT RPERF_STAT_COMMAND RPERF_STAT_REPORT].each do |k|
585
+ v = ENV[k]
586
+ puts "export #{k}=#{Shellwords.escape(v)}" unless v.nil?
587
+ end
588
+ exit 0
589
+ end
590
+
591
+ begin
592
+ exec(*ARGV)
593
+ rescue Errno::ENOENT, Errno::EACCES => e
594
+ $stderr.puts "rperf: failed to execute #{ARGV.first.inspect}: #{e.message}"
595
+ # exec never ran — remove the eagerly created session dir instead of
596
+ # leaving it for the stale-PID sweep
597
+ FileUtils.rm_rf(session_dir) if session_dir
598
+ exit(e.is_a?(Errno::ENOENT) ? 127 : 126)
599
+ end