rperf 0.7.0 → 0.9.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)
@@ -23,6 +24,12 @@ def run_pprof_subcommand(name, banner, min_files:)
23
24
  mode = :text
24
25
  end
25
26
 
27
+ if min_files == 1
28
+ opts.on("--html", "Output static HTML viewer to stdout") do
29
+ mode = :html
30
+ end
31
+ end
32
+
26
33
  opts.on("-h", "--help", "Show this help") do
27
34
  puts opts
28
35
  exit
@@ -31,7 +38,7 @@ def run_pprof_subcommand(name, banner, min_files:)
31
38
 
32
39
  begin
33
40
  parser.order!(ARGV)
34
- rescue OptionParser::InvalidOption => e
41
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument, OptionParser::NeedlessArgument => e
35
42
  $stderr.puts e.message
36
43
  $stderr.puts parser
37
44
  exit 1
@@ -44,7 +51,7 @@ def run_pprof_subcommand(name, banner, min_files:)
44
51
  end
45
52
 
46
53
  files = ARGV.shift(min_files > 1 ? [ARGV.size, min_files].min : 1)
47
- files = ["rperf.data"] if files.empty? && min_files == 1
54
+ files = ["rperf.json.gz"] if files.empty? && min_files == 1
48
55
 
49
56
  if min_files > 1 && files.size < min_files
50
57
  $stderr.puts "Two profile files required."
@@ -59,22 +66,19 @@ def run_pprof_subcommand(name, banner, min_files:)
59
66
  end
60
67
  end
61
68
 
62
- unless system("go", "version", out: File::NULL, err: File::NULL)
63
- $stderr.puts "'go' command not found. Install Go to use 'rperf #{name}'."
64
- $stderr.puts " https://go.dev/dl/"
65
- exit 1
66
- end
67
-
69
+ # Go check is deferred — only needed for pprof files, not marshal/json
68
70
  yield mode, files
69
71
  end
70
72
 
71
- HELP_TEXT = File.read(File.expand_path("../docs/help.md", __dir__))
73
+ def self.help_text
74
+ @help_text ||= File.read(File.expand_path("../docs/help.md", __dir__))
75
+ end
72
76
 
73
77
  USAGE = "Usage: rperf record [options] command [args...]\n" \
74
78
  " rperf stat [options] command [args...]\n" \
75
79
  " rperf exec [options] command [args...]\n" \
76
80
  " rperf report [options] [file]\n" \
77
- " rperf diff [options] base.pb.gz target.pb.gz\n" \
81
+ " rperf diff [options] base target\n" \
78
82
  " rperf help\n"
79
83
 
80
84
  # Handle top-level flags before subcommand parsing
@@ -94,31 +98,127 @@ subcommand = ARGV.shift
94
98
 
95
99
  case subcommand
96
100
  when "help"
97
- puts HELP_TEXT
101
+ puts help_text
98
102
  exit
99
103
  when "report"
100
104
  run_pprof_subcommand("report",
101
105
  "Usage: rperf report [options] [file]\n" \
102
- " Opens pprof profile in browser (default) or prints summary.\n" \
103
- " Default file: rperf.data",
106
+ " Opens profile in browser (default) or prints summary.\n" \
107
+ " Default file: rperf.json.gz\n" \
108
+ " For .json.gz: opens rperf viewer (no Go required).\n" \
109
+ " For .pb.gz: opens go tool pprof (requires Go).",
104
110
  min_files: 1) do |mode, files|
105
111
  report_file = files[0]
106
- case mode
107
- when :top then exec("go", "tool", "pprof", "-top", report_file)
108
- when :text then exec("go", "tool", "pprof", "-text", report_file)
109
- else exec("go", "tool", "pprof", "-http=localhost:#{find_available_port}", report_file)
112
+ if report_file =~ /\.json(\.gz)?\z/
113
+ # Use rperf viewer
114
+ require_relative "../lib/rperf"
115
+ require_relative "../lib/rperf/viewer"
116
+
117
+ data = Rperf.load(report_file)
118
+
119
+ case mode
120
+ when :top
121
+ $stdout.puts Rperf::Text.encode(data, top_n: 20, header: false)
122
+ exit
123
+ when :text
124
+ $stdout.puts Rperf::Text.encode(data)
125
+ exit
126
+ when :html
127
+ $stdout.puts Rperf::Viewer.render_static_html(data)
128
+ exit
129
+ end
130
+
131
+ port = find_available_port
132
+
133
+ app = Rperf::Viewer.new(
134
+ proc { |_| [404, { "content-type" => "text/plain" }, ["Not Found"]] },
135
+ path: ""
136
+ )
137
+ 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
155
+ 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"
163
+ exit 1
164
+ end
165
+ handler = Rackup::Handler.default
166
+ handler.run(app, Port: port, Host: "localhost", Silent: true)
167
+ exit
168
+ else
169
+ unless system("go", "version", out: File::NULL, err: File::NULL)
170
+ $stderr.puts "'go' command not found. Install Go to use pprof files, or use .json.gz format."
171
+ $stderr.puts " https://go.dev/dl/"
172
+ exit 1
173
+ end
174
+ case mode
175
+ when :top then exec("go", "tool", "pprof", "-top", report_file)
176
+ when :text then exec("go", "tool", "pprof", "-text", report_file)
177
+ else exec("go", "tool", "pprof", "-http=localhost:#{find_available_port}", report_file)
178
+ end
110
179
  end
111
180
  end
112
181
  when "diff"
113
182
  run_pprof_subcommand("diff",
114
- "Usage: rperf diff [options] base.pb.gz target.pb.gz\n" \
115
- " Compare two pprof profiles (shows target - base).",
183
+ "Usage: rperf diff [options] base target\n" \
184
+ " Compare two profiles (shows target - base).\n" \
185
+ " Accepts .json.gz (auto-converted) or .pb.gz files.\n" \
186
+ " Requires Go (https://go.dev/dl/).",
116
187
  min_files: 2) do |mode, files|
188
+ unless system("go", "version", out: File::NULL, err: File::NULL)
189
+ $stderr.puts "'go' command not found. Install Go to use 'rperf diff'."
190
+ $stderr.puts " https://go.dev/dl/"
191
+ exit 1
192
+ end
193
+
194
+ tmpfiles = []
195
+ files.each_with_index do |f, i|
196
+ if f =~ /\.json(\.gz)?\z/
197
+ require "tempfile"
198
+ require_relative "../lib/rperf"
199
+ data = Rperf.load(f)
200
+ tmp = Tempfile.new(["rperf_diff_#{i}", ".pb.gz"])
201
+ tmp.binmode
202
+ tmp.write(Rperf.__send__(:gzip, Rperf::PProf.encode(data)))
203
+ tmp.close
204
+ tmpfiles << tmp
205
+ files[i] = tmp.path
206
+ end
207
+ end
208
+
117
209
  base_file, target_file = files
118
- case mode
119
- when :top then exec("go", "tool", "pprof", "-top", "-diff_base=#{base_file}", target_file)
120
- when :text then exec("go", "tool", "pprof", "-text", "-diff_base=#{base_file}", target_file)
121
- else exec("go", "tool", "pprof", "-http=localhost:#{find_available_port}", "-diff_base=#{base_file}", target_file)
210
+ pprof_args = case mode
211
+ when :top then ["-top", "-diff_base=#{base_file}", target_file]
212
+ when :text then ["-text", "-diff_base=#{base_file}", target_file]
213
+ else ["-http=localhost:#{find_available_port}", "-diff_base=#{base_file}", target_file]
214
+ end
215
+ if tmpfiles.empty?
216
+ exec("go", "tool", "pprof", *pprof_args)
217
+ else
218
+ pid = spawn("go", "tool", "pprof", *pprof_args)
219
+ _, status = Process.wait2(pid)
220
+ tmpfiles.each(&:unlink)
221
+ exit(status.exitstatus || 1)
122
222
  end
123
223
  end
124
224
  when "record", "stat", "exec"
@@ -129,7 +229,7 @@ else
129
229
  exit 1
130
230
  end
131
231
 
132
- output = (subcommand == "record") ? "rperf.data" : nil
232
+ output = (subcommand == "record") ? "rperf.json.gz" : nil
133
233
  frequency = 1000
134
234
  mode = (subcommand == "record") ? "cpu" : "wall"
135
235
  format = nil
@@ -137,6 +237,7 @@ signal = nil
137
237
  verbose = false
138
238
  aggregate = true
139
239
  stat_report = (subcommand == "exec")
240
+ inherit = true
140
241
 
141
242
  parser = OptionParser.new do |opts|
142
243
  opts.banner = case subcommand
@@ -145,7 +246,7 @@ parser = OptionParser.new do |opts|
145
246
  when "exec" then "Usage: rperf exec [options] command [args...]"
146
247
  end
147
248
 
148
- opts.on("-o", "--output PATH", "Output file#{subcommand == 'record' ? ' (default: rperf.data)' : ' (default: none)'}") do |v|
249
+ opts.on("-o", "--output PATH", "Output file#{subcommand == 'record' ? ' (default: rperf.json.gz)' : ' (default: none)'}") do |v|
149
250
  output = v
150
251
  end
151
252
 
@@ -159,8 +260,8 @@ parser = OptionParser.new do |opts|
159
260
  end
160
261
 
161
262
  if subcommand == "record"
162
- opts.on("--format FORMAT", %w[pprof collapsed text],
163
- "Output format: pprof, collapsed, or text (default: auto from extension)") do |v|
263
+ opts.on("--format FORMAT", %w[pprof collapsed text json],
264
+ "Output format: json, pprof, collapsed, or text (default: auto from extension)") do |v|
164
265
  format = v
165
266
  end
166
267
 
@@ -171,7 +272,14 @@ parser = OptionParser.new do |opts|
171
272
  end
172
273
 
173
274
  opts.on("--signal VALUE", "Timer signal (Linux only): signal number, or 'false' for nanosleep thread") do |v|
174
- signal = (v == "false") ? "false" : v
275
+ if v == "false"
276
+ signal = "false"
277
+ elsif v =~ /\A\d+\z/
278
+ signal = v
279
+ else
280
+ $stderr.puts "Error: --signal must be a signal number or 'false', got: #{v.inspect}"
281
+ exit 1
282
+ end
175
283
  end
176
284
 
177
285
  opts.on("--no-aggregate", "Disable sample aggregation (keep raw samples)") do
@@ -184,6 +292,10 @@ parser = OptionParser.new do |opts|
184
292
  end
185
293
  end
186
294
 
295
+ opts.on("--no-inherit", "Do not profile forked/spawned child processes (default: inherit)") do
296
+ inherit = false
297
+ end
298
+
187
299
  opts.on("-v", "--verbose", "Print sampling statistics to stderr") do
188
300
  verbose = true
189
301
  end
@@ -198,7 +310,7 @@ end
198
310
 
199
311
  begin
200
312
  parser.order!(ARGV)
201
- rescue OptionParser::InvalidOption => e
313
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument, OptionParser::NeedlessArgument => e
202
314
  $stderr.puts e.message
203
315
  $stderr.puts parser
204
316
  exit 1
@@ -233,7 +345,9 @@ if signal && signal != "false"
233
345
  end
234
346
  end
235
347
 
236
- # Add lib dir to RUBYLIB so -rrperf can find the extension
348
+ # Add lib dir to RUBYLIB so -rrperf can find the correct version.
349
+ # RUBYLIB handles spaces in paths safely (PATH_SEPARATOR delimited).
350
+ # RUBYOPT -r<path> does not support spaces, so we use RUBYLIB + -rrperf.
237
351
  lib_dir = File.expand_path("../lib", __dir__)
238
352
  ENV["RUBYLIB"] = [lib_dir, ENV["RUBYLIB"]].compact.join(File::PATH_SEPARATOR)
239
353
  ENV["RUBYOPT"] = "-rrperf #{ENV['RUBYOPT']}".strip
@@ -252,4 +366,14 @@ if subcommand == "stat" || subcommand == "exec"
252
366
  ENV["RPERF_STAT_REPORT"] = "1" if stat_report
253
367
  end
254
368
 
369
+ # Multi-process (fork) support: create a session directory for aggregation
370
+ if inherit
371
+ require_relative "../lib/rperf"
372
+ session_dir = Rperf.send(:_create_session_dir, clean_stale: true)
373
+ if session_dir
374
+ ENV["RPERF_ROOT_PROCESS"] = Process.pid.to_s
375
+ ENV["RPERF_SESSION_DIR"] = session_dir
376
+ end
377
+ end
378
+
255
379
  exec(*ARGV)