hayaoki 0.1.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 +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE +21 -0
- data/README.md +118 -0
- data/exe/hayaoki +7 -0
- data/lib/hayaoki/boot_report/advisor.rb +113 -0
- data/lib/hayaoki/boot_report/aggregator.rb +367 -0
- data/lib/hayaoki/boot_report/formatters/json.rb +52 -0
- data/lib/hayaoki/boot_report/formatters/redact.rb +32 -0
- data/lib/hayaoki/boot_report/formatters/speedscope.rb +118 -0
- data/lib/hayaoki/boot_report/formatters/text.rb +275 -0
- data/lib/hayaoki/boot_report/gemfile.rb +146 -0
- data/lib/hayaoki/boot_report/report.rb +22 -0
- data/lib/hayaoki/boot_report/rules/base.rb +113 -0
- data/lib/hayaoki/boot_report/rules/bootsnap_ineffective.rb +88 -0
- data/lib/hayaoki/boot_report/rules/controller_action_methods.rb +33 -0
- data/lib/hayaoki/boot_report/rules/dev_gems_in_production.rb +80 -0
- data/lib/hayaoki/boot_report/rules/devise_reload_routes.rb +43 -0
- data/lib/hayaoki/boot_report/rules/eager_load_tradeoff.rb +32 -0
- data/lib/hayaoki/boot_report/rules/heavy_app_initializer.rb +59 -0
- data/lib/hayaoki/boot_report/rules/tzinfo_eager_load.rb +33 -0
- data/lib/hayaoki/boot_report/rules/unused_heavy_gem.rb +149 -0
- data/lib/hayaoki/boot_report/runner.rb +472 -0
- data/lib/hayaoki/cli.rb +210 -0
- data/lib/hayaoki/probe.rb +513 -0
- data/lib/hayaoki/version.rb +5 -0
- data/lib/hayaoki.rb +28 -0
- metadata +70 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../../hayaoki"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module Hayaoki
|
|
9
|
+
module BootReport
|
|
10
|
+
PROBE_PATH = File.expand_path("../probe.rb", __dir__)
|
|
11
|
+
|
|
12
|
+
# 子プロセスの失敗。stderr の末尾と、あれば部分結果 (payload) を持つ。
|
|
13
|
+
class RunError < Hayaoki::Error
|
|
14
|
+
attr_reader :reason, :stderr_tail, :partial, :exit_status, :open_span, :probe_error
|
|
15
|
+
|
|
16
|
+
# reason: :exit_status / :no_output / :timeout / :boot_error
|
|
17
|
+
def initialize(reason, summary, stderr_tail: "", partial: nil, exit_status: nil)
|
|
18
|
+
@reason = reason
|
|
19
|
+
@stderr_tail = stderr_tail.to_s
|
|
20
|
+
@partial = partial
|
|
21
|
+
@exit_status = exit_status
|
|
22
|
+
@probe_error = partial && partial["error"]
|
|
23
|
+
@open_span = partial && RunError.find_open_span(partial)
|
|
24
|
+
super(build_message(summary))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# 最後に開いていた span (未完了のうち最も深いもの、無ければ最後の raised)。
|
|
28
|
+
def self.find_open_span(payload)
|
|
29
|
+
w = payload["width"] || 8
|
|
30
|
+
ev = payload["events"] || []
|
|
31
|
+
open = nil
|
|
32
|
+
raised = nil
|
|
33
|
+
(0...(ev.size / w)).each do |i|
|
|
34
|
+
kind, name, _par, _t0, t1, res = ev[i * w, 6]
|
|
35
|
+
open = [kind, name] if t1.nil?
|
|
36
|
+
raised = [kind, name] if res == "raised"
|
|
37
|
+
end
|
|
38
|
+
found = open || raised
|
|
39
|
+
found && { "kind" => found[0], "name" => found[1] }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def build_message(summary)
|
|
45
|
+
msg = +summary
|
|
46
|
+
if @probe_error
|
|
47
|
+
msg << "\nerror: #{@probe_error["class"]}: #{@probe_error["message"]}"
|
|
48
|
+
end
|
|
49
|
+
msg << "\nlast open span: kind=#{@open_span["kind"]} #{@open_span["name"]}" if @open_span
|
|
50
|
+
msg << "\n--- stderr (tail) ---\n#{@stderr_tail}" unless @stderr_tail.empty?
|
|
51
|
+
msg
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# 条件ごと ("warm" / "cold") の payload の配列と、メタデータ。
|
|
56
|
+
class RunResult
|
|
57
|
+
attr_reader :payloads, :meta
|
|
58
|
+
|
|
59
|
+
def initialize(payloads, meta)
|
|
60
|
+
@payloads = payloads
|
|
61
|
+
@meta = meta
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def conditions = payloads.keys
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class Runner
|
|
68
|
+
STDERR_TAIL_BYTES = 4096
|
|
69
|
+
KILL_GRACE = 3.0
|
|
70
|
+
BOOTSNAP_MODES = %i[warm cold both].freeze
|
|
71
|
+
|
|
72
|
+
attr_reader :app_dir, :runs, :env, :bootsnap, :ruby, :entry, :timeout
|
|
73
|
+
|
|
74
|
+
def initialize(app_dir:, runs: 5, env: "production", bootsnap: :warm, ruby: "ruby", entry: "config/environment", timeout: 120, on_progress: nil)
|
|
75
|
+
raise ArgumentError, "bootsnap must be one of #{BOOTSNAP_MODES.inspect}" unless BOOTSNAP_MODES.include?(bootsnap)
|
|
76
|
+
raise ArgumentError, "runs must be >= 1" unless runs.is_a?(Integer) && runs >= 1
|
|
77
|
+
@app_dir = File.expand_path(app_dir)
|
|
78
|
+
@runs = runs
|
|
79
|
+
@env = env
|
|
80
|
+
@bootsnap = bootsnap
|
|
81
|
+
@ruby = ruby
|
|
82
|
+
@entry = entry
|
|
83
|
+
@timeout = timeout
|
|
84
|
+
@on_progress = on_progress
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def command(probe: true)
|
|
88
|
+
[ruby, *(probe ? ["-r", PROBE_PATH] : []), "-e", script(probe)]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# 基準実行は entry の require 直後の実時刻を一時ファイル (HAYAOKI_BASE_MARK) に書く。
|
|
92
|
+
def script(probe)
|
|
93
|
+
req = "require File.expand_path(#{entry.inspect}, Dir.pwd)"
|
|
94
|
+
return req if probe
|
|
95
|
+
"#{req}; File.write(ENV.fetch('HAYAOKI_BASE_MARK'), Process.clock_gettime(Process::CLOCK_REALTIME).to_s)"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# 全 run が失敗した場合 (条件ごと) などに RunError を raise する。一部の run の失敗は meta["failed_runs"] に記録して続行する。
|
|
99
|
+
def run
|
|
100
|
+
check_app!
|
|
101
|
+
check_ruby!
|
|
102
|
+
@gemfile_present = nil
|
|
103
|
+
gemfile_present?
|
|
104
|
+
conditions = bootsnap == :both ? %i[warm cold] : [bootsnap]
|
|
105
|
+
payloads = {}
|
|
106
|
+
overhead = {}
|
|
107
|
+
warnings = []
|
|
108
|
+
@failures = []
|
|
109
|
+
@interrupted = nil
|
|
110
|
+
@child = nil
|
|
111
|
+
old_traps = install_traps
|
|
112
|
+
begin
|
|
113
|
+
Dir.mktmpdir("hayaoki-run") do |tmp|
|
|
114
|
+
@tmp = tmp
|
|
115
|
+
@seq = 0
|
|
116
|
+
conditions.each do |cond|
|
|
117
|
+
payloads[cond.to_s], ov = measure(cond, warnings)
|
|
118
|
+
overhead[cond] = ov if ov
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
rescue Interrupted
|
|
122
|
+
# 子の後始末は済んでいる。元の trap に戻してから、そのシグナルを元のハンドラに渡す
|
|
123
|
+
olds = old_traps
|
|
124
|
+
old_traps = nil
|
|
125
|
+
restore_traps(olds)
|
|
126
|
+
redeliver(@interrupted, olds[Signal.signame(@interrupted)])
|
|
127
|
+
raise RunError.new(:interrupted, "interrupted by SIG#{Signal.signame(@interrupted)}")
|
|
128
|
+
ensure
|
|
129
|
+
restore_traps(old_traps) if old_traps
|
|
130
|
+
end
|
|
131
|
+
meta = {
|
|
132
|
+
"app_dir" => app_dir, "runs" => runs, "env" => env, "bootsnap" => bootsnap.to_s, "ruby" => ruby,
|
|
133
|
+
"entry" => entry, "conditions" => conditions.map(&:to_s), "warnings" => warnings.uniq,
|
|
134
|
+
"overhead" => overhead.transform_keys(&:to_s), "failed_runs" => @failures,
|
|
135
|
+
}
|
|
136
|
+
RunResult.new(payloads, meta)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def measure(cond, warnings)
|
|
142
|
+
warm_dir = cond == :warm ? new_dir("cache-warm") : nil
|
|
143
|
+
# ウォームアップ (捨てる 1 回): OS のファイルキャッシュと、warm 条件では bootsnap のキャッシュを温める
|
|
144
|
+
progress(:warmup, cond, 0)
|
|
145
|
+
guarded(cond, :warmup, 0) { execute(probe: true, cache_dir: warm_dir || new_dir("cache-cold")) }
|
|
146
|
+
results = []
|
|
147
|
+
base_ms = []
|
|
148
|
+
cache_used = false
|
|
149
|
+
first_error = nil
|
|
150
|
+
runs.times do |i|
|
|
151
|
+
cache = warm_dir || new_dir("cache-cold")
|
|
152
|
+
progress(:probe, cond, i + 1)
|
|
153
|
+
r = guarded(cond, :probe, i + 1) { execute(probe: true, cache_dir: cache) }
|
|
154
|
+
if r.is_a?(RunError)
|
|
155
|
+
first_error ||= r
|
|
156
|
+
results << failed_payload(r)
|
|
157
|
+
else
|
|
158
|
+
results << r[:payload]
|
|
159
|
+
end
|
|
160
|
+
cache_used ||= !Dir.empty?(cache)
|
|
161
|
+
progress(:baseline, cond, i + 1)
|
|
162
|
+
b = guarded(cond, :baseline, i + 1) { execute(probe: false, cache_dir: warm_dir || new_dir("cache-cold")) }
|
|
163
|
+
base_ms << b[:boot_ms] unless b.is_a?(RunError)
|
|
164
|
+
end
|
|
165
|
+
ok = results.select { |p| p["status"] == "ok" }
|
|
166
|
+
if ok.empty?
|
|
167
|
+
# 全ての run が失敗: 最初の失敗を (記録済みの内容ごと) 返して終了する
|
|
168
|
+
raise first_error
|
|
169
|
+
end
|
|
170
|
+
cache_used ||= !Dir.empty?(warm_dir) if warm_dir
|
|
171
|
+
unless cache_used
|
|
172
|
+
warnings << "[#{cond}] BOOTSNAP_CACHE_DIR was not used (the app may call Bootsnap.setup with its own cache_dir, or bootsnap is not loaded); bootsnap #{cond} measurement may not be meaningful"
|
|
173
|
+
end
|
|
174
|
+
[results, base_ms.empty? ? nil : overhead(ok, base_ms)]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# RunError を meta 用に記録して返す (Interrupted は素通し)。成功ならブロックの値。
|
|
178
|
+
def guarded(cond, phase, index)
|
|
179
|
+
yield
|
|
180
|
+
rescue RunError => e
|
|
181
|
+
@failures << { "condition" => cond.to_s, "phase" => phase.to_s, "index" => index, "reason" => e.reason.to_s,
|
|
182
|
+
"message" => e.message.lines.first.to_s.strip, "exit_status" => e.exit_status,
|
|
183
|
+
"error_class" => e.probe_error && e.probe_error["class"], "stderr_tail" => e.stderr_tail }
|
|
184
|
+
e
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# 失敗した run を集計側が除外できる形 (status が ok 以外) にする。
|
|
188
|
+
def failed_payload(err)
|
|
189
|
+
base = err.partial.is_a?(Hash) ? err.partial.dup : {}
|
|
190
|
+
base["status"] = "failed" if base["status"].nil? || base["status"] == "ok"
|
|
191
|
+
base["failure_reason"] = err.reason.to_s
|
|
192
|
+
base
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# index: warmup は 0、probe / baseline は 1 始まり。total は計測 run 数。
|
|
196
|
+
def progress(phase, cond, index)
|
|
197
|
+
@on_progress&.call({ "phase" => phase, "bootsnap" => cond, "index" => index, "total" => runs })
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def overhead(payloads, base_ms)
|
|
201
|
+
probe_ms = payloads.map do |p|
|
|
202
|
+
boot_us = p["boot_end_us"] || p["exit_us"]
|
|
203
|
+
lead = p["spawn_at"] ? (p["t0_real"] - p["spawn_at"].to_f) * 1000.0 : 0.0
|
|
204
|
+
lead + boot_us / 1000.0
|
|
205
|
+
end
|
|
206
|
+
b = median(base_ms)
|
|
207
|
+
p = median(probe_ms)
|
|
208
|
+
{ "baseline_ms" => b.round(3), "probe_ms" => p.round(3), "diff_ms" => (p - b).round(3), "ratio" => b.positive? ? (p / b).round(4) : nil,
|
|
209
|
+
"baseline_samples_ms" => base_ms.map { _1.round(3) }, "probe_samples_ms" => probe_ms.map { _1.round(3) } }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def median(a)
|
|
213
|
+
s = a.sort
|
|
214
|
+
n = s.size
|
|
215
|
+
n.odd? ? s[n / 2] : (s[n / 2 - 1] + s[n / 2]) / 2.0
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def new_dir(prefix)
|
|
219
|
+
d = File.join(@tmp, "#{prefix}-#{@seq += 1}")
|
|
220
|
+
FileUtils.mkdir_p(d)
|
|
221
|
+
d
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# 1 回実行する。probe 有りなら payload を読んで返す。{ payload:, wall: }
|
|
225
|
+
def execute(probe:, cache_dir:)
|
|
226
|
+
pid = nil
|
|
227
|
+
run_child(probe: probe, cache_dir: cache_dir) { |p| pid = p }
|
|
228
|
+
ensure
|
|
229
|
+
reap_group(pid) if pid
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def run_child(probe:, cache_dir:)
|
|
233
|
+
raise Interrupted if @interrupted
|
|
234
|
+
out = File.join(@tmp, "out-#{@seq += 1}.json")
|
|
235
|
+
errf = File.join(@tmp, "err-#{@seq}.log")
|
|
236
|
+
child_env = { "RAILS_ENV" => env, "RACK_ENV" => env, "BOOTSNAP_CACHE_DIR" => cache_dir, "DISABLE_SPRING" => "1" }
|
|
237
|
+
mark = File.join(@tmp, "mark-#{@seq}.txt")
|
|
238
|
+
child_env["HAYAOKI_PROBE_OUT"] = out if probe
|
|
239
|
+
child_env["HAYAOKI_BASE_MARK"] = mark unless probe
|
|
240
|
+
child_env["BUNDLE_FROZEN"] = "true" if gemfile_present?
|
|
241
|
+
pid = nil
|
|
242
|
+
t0 = nil
|
|
243
|
+
spawn_real = nil
|
|
244
|
+
with_clean_env do |base|
|
|
245
|
+
e = base.merge(child_env)
|
|
246
|
+
e["HAYAOKI_SPAWN_AT"] = nil
|
|
247
|
+
opts = { chdir: app_dir, in: File::NULL, out: File::NULL, err: errf, pgroup: true }
|
|
248
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
249
|
+
spawn_real = Process.clock_gettime(Process::CLOCK_REALTIME)
|
|
250
|
+
e["HAYAOKI_SPAWN_AT"] = spawn_real.to_s if probe
|
|
251
|
+
pid = Process.spawn(e, *command(probe: probe), **opts)
|
|
252
|
+
@child = pid
|
|
253
|
+
yield pid
|
|
254
|
+
end
|
|
255
|
+
status, timed_out = wait(pid)
|
|
256
|
+
@child = nil
|
|
257
|
+
@reaped_pid = pid
|
|
258
|
+
raise Interrupted if @interrupted
|
|
259
|
+
wall = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
260
|
+
tail = read_tail(errf)
|
|
261
|
+
payload = File.exist?(out) ? parse(out) : nil
|
|
262
|
+
corrupt = payload.nil? && File.exist?(out)
|
|
263
|
+
|
|
264
|
+
if timed_out
|
|
265
|
+
raise RunError.new(:timeout, "boot did not finish within #{timeout}s (killed)", stderr_tail: tail, partial: payload)
|
|
266
|
+
end
|
|
267
|
+
unless status.success?
|
|
268
|
+
raise RunError.new(payload ? :boot_error : :exit_status, "child exited with status #{status.exitstatus.inspect}#{status.signaled? ? " (signal #{status.termsig})" : ""}",
|
|
269
|
+
stderr_tail: tail, partial: payload, exit_status: status.exitstatus)
|
|
270
|
+
end
|
|
271
|
+
if probe
|
|
272
|
+
if payload.nil?
|
|
273
|
+
msg = if corrupt
|
|
274
|
+
"probe output was not valid JSON (the file was truncated or overwritten, e.g. the app wrote to HAYAOKI_PROBE_OUT or the process was killed while writing)"
|
|
275
|
+
else
|
|
276
|
+
"probe wrote no output (the app may have called exit!/exec, or replaced the process)"
|
|
277
|
+
end
|
|
278
|
+
raise RunError.new(:no_output, msg, stderr_tail: tail, exit_status: 0)
|
|
279
|
+
end
|
|
280
|
+
if payload["status"] != "ok"
|
|
281
|
+
raise RunError.new(:boot_error, "boot failed (probe status=#{payload["status"]})", stderr_tail: tail, partial: payload, exit_status: 0)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
boot_ms = nil
|
|
285
|
+
unless probe
|
|
286
|
+
end_at = (Float(File.read(mark)) rescue nil)
|
|
287
|
+
raise RunError.new(:no_output, "baseline run did not reach the end of entry (marker missing)", stderr_tail: tail, exit_status: 0) unless end_at
|
|
288
|
+
boot_ms = (end_at - spawn_real) * 1000.0
|
|
289
|
+
end
|
|
290
|
+
{ payload: payload, wall: wall, boot_ms: boot_ms }
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def parse(path)
|
|
294
|
+
JSON.parse(File.read(path))
|
|
295
|
+
rescue JSON::ParserError
|
|
296
|
+
nil
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def read_tail(path)
|
|
300
|
+
File.open(path, "rb") do |f|
|
|
301
|
+
size = f.size
|
|
302
|
+
f.seek([size - STDERR_TAIL_BYTES, 0].max)
|
|
303
|
+
f.read.to_s.scrub
|
|
304
|
+
end
|
|
305
|
+
rescue SystemCallError
|
|
306
|
+
""
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# [Process::Status, timed_out]
|
|
310
|
+
def wait(pid)
|
|
311
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
312
|
+
loop do
|
|
313
|
+
r = Process.wait2(pid, Process::WNOHANG)
|
|
314
|
+
return [r[1], false] if r
|
|
315
|
+
break if @interrupted || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
316
|
+
sleep 0.005
|
|
317
|
+
end
|
|
318
|
+
# SIGTERM で probe に部分結果を書かせ、だめなら KILL
|
|
319
|
+
signal_group("TERM", pid)
|
|
320
|
+
grace = Process.clock_gettime(Process::CLOCK_MONOTONIC) + KILL_GRACE
|
|
321
|
+
until (r = Process.wait2(pid, Process::WNOHANG))
|
|
322
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > grace
|
|
323
|
+
signal_group("KILL", pid)
|
|
324
|
+
return [Process.wait2(pid)[1], true]
|
|
325
|
+
end
|
|
326
|
+
sleep 0.01
|
|
327
|
+
end
|
|
328
|
+
[r[1], true]
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def signal_group(sig, pid)
|
|
332
|
+
Process.kill(sig, -pid)
|
|
333
|
+
rescue SystemCallError
|
|
334
|
+
begin
|
|
335
|
+
Process.kill(sig, pid)
|
|
336
|
+
rescue SystemCallError
|
|
337
|
+
nil
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# 内部用: シグナルで中断された (子の後始末は済んでいる)。
|
|
342
|
+
class Interrupted < StandardError; end
|
|
343
|
+
|
|
344
|
+
def check_app!
|
|
345
|
+
return if File.directory?(app_dir)
|
|
346
|
+
raise RunError.new(:app_dir, "APP_DIR not found: #{app_dir}")
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
MIN_RUBY = Gem::Version.new("3.2")
|
|
350
|
+
|
|
351
|
+
# 子プロセスの ruby (既定は PATH 上の ruby。rbenv などはアプリのディレクトリで版を選ぶ) が probe を読めるか。
|
|
352
|
+
# 古い ruby だと probe の構文エラーが大量に出るだけで原因が分からないので、先に版を確かめる。
|
|
353
|
+
def check_ruby!
|
|
354
|
+
out, st = with_clean_env do |base|
|
|
355
|
+
IO.popen(base, [ruby, "-e", "print RUBY_VERSION"], chdir: app_dir, err: File::NULL, &:read).then { [_1, $?] }
|
|
356
|
+
end
|
|
357
|
+
v = out.to_s.strip
|
|
358
|
+
raise RunError.new(:ruby, "could not run `#{ruby} -e 'print RUBY_VERSION'` in #{app_dir}") unless st&.success? && v.match?(/\A\d+\.\d+/)
|
|
359
|
+
return if Gem::Version.new(v) >= MIN_RUBY
|
|
360
|
+
raise RunError.new(:ruby, "`#{ruby}` in #{app_dir} is Ruby #{v}, but the probe needs Ruby #{MIN_RUBY} or later. " \
|
|
361
|
+
"Pass the app's ruby with --ruby PATH, or fix PATH / .ruby-version.")
|
|
362
|
+
rescue SystemCallError => e
|
|
363
|
+
raise RunError.new(:ruby, "ruby executable not found: #{ruby} (#{e.class}). Pass it with --ruby PATH.")
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Gemfile があるアプリは Gemfile.lock が必須 (子に BUNDLE_FROZEN=true を渡すので、lock の生成・更新をさせない)。
|
|
367
|
+
def gemfile_present?
|
|
368
|
+
return @gemfile_present unless @gemfile_present.nil?
|
|
369
|
+
gemfile = with_clean_env { |base| base["BUNDLE_GEMFILE"].to_s.empty? ? File.join(app_dir, "Gemfile") : File.expand_path(base["BUNDLE_GEMFILE"], app_dir) }
|
|
370
|
+
@gemfile_present = File.file?(gemfile)
|
|
371
|
+
if @gemfile_present
|
|
372
|
+
lock = "#{gemfile}.lock"
|
|
373
|
+
unless File.file?(lock)
|
|
374
|
+
raise RunError.new(:no_lockfile, "#{lock} not found. hayaoki does not modify your app, so run `bundle lock` in #{app_dir} first (or `bundle install`).")
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
@gemfile_present
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def install_traps
|
|
381
|
+
%w[INT TERM].to_h do |name|
|
|
382
|
+
signo = Signal.list.fetch(name)
|
|
383
|
+
old = Signal.trap(name) do
|
|
384
|
+
@interrupted ||= signo
|
|
385
|
+
signal_group("TERM", @child) if @child
|
|
386
|
+
end
|
|
387
|
+
[name, old]
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def restore_traps(olds)
|
|
392
|
+
olds.each { |name, old| Signal.trap(name, old || "DEFAULT") }
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# 元のハンドラに、受け取ったシグナルを渡す。DEFAULT ならそのシグナルで自分が終了する (終了ステータスが保たれる)。
|
|
396
|
+
def redeliver(signo, old)
|
|
397
|
+
name = Signal.signame(signo)
|
|
398
|
+
case old
|
|
399
|
+
when Proc then old.call(signo)
|
|
400
|
+
when "DEFAULT", "SYSTEM_DEFAULT", nil, ""
|
|
401
|
+
Signal.trap(name, "DEFAULT")
|
|
402
|
+
Process.kill(name, Process.pid)
|
|
403
|
+
sleep 1 # シグナルが配送されるまで
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# 子のプロセスグループが残っていれば TERM → 猶予 → KILL で片付け、回収する。
|
|
408
|
+
def reap_group(pid)
|
|
409
|
+
return if @reaped_pid == pid
|
|
410
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + KILL_GRACE
|
|
411
|
+
signal_group("TERM", pid)
|
|
412
|
+
loop do
|
|
413
|
+
begin
|
|
414
|
+
r = Process.wait2(pid, Process::WNOHANG)
|
|
415
|
+
rescue Errno::ECHILD
|
|
416
|
+
break
|
|
417
|
+
end
|
|
418
|
+
break if r
|
|
419
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
420
|
+
signal_group("KILL", pid)
|
|
421
|
+
Process.wait2(pid) rescue nil
|
|
422
|
+
break
|
|
423
|
+
end
|
|
424
|
+
sleep 0.01
|
|
425
|
+
end
|
|
426
|
+
@reaped_pid = pid
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# アプリ側の bundle exec が注入したものだけを除く。利用者が設定した BUNDLE_* (BUNDLE_WITHOUT / GEMFILE / PATH / DEPLOYMENT など) は残す。
|
|
430
|
+
# ブロックには環境変数の Hash を渡す (spawn はこの中で行う)。
|
|
431
|
+
def with_clean_env(&block)
|
|
432
|
+
if defined?(::Bundler) && ::Bundler.respond_to?(:with_original_env)
|
|
433
|
+
::Bundler.with_original_env { block.call(strip_injected(ENV.to_h)) }
|
|
434
|
+
else
|
|
435
|
+
block.call(strip_injected(ENV.to_h))
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def bundler_lib_dirs
|
|
440
|
+
dirs = []
|
|
441
|
+
if defined?(::Bundler) && ::Bundler.respond_to?(:with_original_env)
|
|
442
|
+
loc = ::Bundler.method(:with_original_env).source_location
|
|
443
|
+
dirs << File.dirname(loc[0]) if loc
|
|
444
|
+
end
|
|
445
|
+
dirs
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def strip_injected(h)
|
|
449
|
+
h = h.reject { |k, _| %w[BUNDLE_BIN_PATH BUNDLER_SETUP BUNDLER_VERSION BUNDLER_ORIG_RUBYOPT BUNDLER_ORIG_RUBYLIB].include?(k) }
|
|
450
|
+
if (rl = h["RUBYLIB"])
|
|
451
|
+
dirs = bundler_lib_dirs
|
|
452
|
+
kept = rl.split(File::PATH_SEPARATOR).reject { |d| dirs.include?(d) || d =~ %r{/bundler[^/]*/lib/?\z} }
|
|
453
|
+
kept.empty? ? h.delete("RUBYLIB") : h["RUBYLIB"] = kept.join(File::PATH_SEPARATOR)
|
|
454
|
+
end
|
|
455
|
+
if (ro = h["RUBYOPT"])
|
|
456
|
+
toks = ro.split(/\s+/).reject(&:empty?)
|
|
457
|
+
kept = []
|
|
458
|
+
until toks.empty?
|
|
459
|
+
t = toks.shift
|
|
460
|
+
if t == "-r" && toks.first == "bundler/setup"
|
|
461
|
+
toks.shift
|
|
462
|
+
elsif t != "-rbundler/setup"
|
|
463
|
+
kept << t
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
kept.empty? ? h.delete("RUBYOPT") : h["RUBYOPT"] = kept.join(" ")
|
|
467
|
+
end
|
|
468
|
+
h
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
end
|
data/lib/hayaoki/cli.rb
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "../hayaoki"
|
|
5
|
+
require_relative "version"
|
|
6
|
+
|
|
7
|
+
module Hayaoki
|
|
8
|
+
class CLI
|
|
9
|
+
FORMATS = %w[text json speedscope].freeze
|
|
10
|
+
USAGE = "Usage: hayaoki boot-report [options] [APP_DIR]"
|
|
11
|
+
|
|
12
|
+
def self.start(argv = ARGV, stdout: $stdout, stderr: $stderr)
|
|
13
|
+
new(stdout: stdout, stderr: stderr).run(argv)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(stdout: $stdout, stderr: $stderr)
|
|
17
|
+
@out = stdout
|
|
18
|
+
@err = stderr
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# 終了コードを返す。
|
|
22
|
+
def run(argv)
|
|
23
|
+
argv = argv.dup
|
|
24
|
+
cmd = argv.shift
|
|
25
|
+
case cmd
|
|
26
|
+
when "boot-report" then boot_report(argv)
|
|
27
|
+
when "--version", "-v", "version" then @out.puts("hayaoki #{Hayaoki::VERSION}"); 0
|
|
28
|
+
when nil, "-h", "--help", "help" then @out.puts(top_help); cmd.nil? ? 1 : 0
|
|
29
|
+
else
|
|
30
|
+
@err.puts "hayaoki: unknown command '#{cmd}'\n#{top_help}"
|
|
31
|
+
1
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def default_options
|
|
36
|
+
{ runs: 5, env: "production", bootsnap: :warm, ruby: "ruby", entry: "config/environment", timeout: 120,
|
|
37
|
+
format: "text", output: nil, top: 10 }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# オプション解析だけ行う。[options, app_dir] を返す (--help / --version は :exit を返す)
|
|
41
|
+
def parse(argv)
|
|
42
|
+
opts = default_options
|
|
43
|
+
parser = OptionParser.new do |o|
|
|
44
|
+
o.banner = USAGE
|
|
45
|
+
o.on("--runs N", Integer, "number of measured runs (default 5)") { |v| opts[:runs] = v }
|
|
46
|
+
o.on("--env ENV", "RAILS_ENV (default production)") { |v| opts[:env] = v }
|
|
47
|
+
o.on("--bootsnap MODE", %w[warm cold both], "warm|cold|both (default warm)") { |v| opts[:bootsnap] = v.to_sym }
|
|
48
|
+
o.on("--ruby PATH", "ruby executable (default ruby)") { |v| opts[:ruby] = v }
|
|
49
|
+
o.on("--entry PATH", "boot entry file (default config/environment)") { |v| opts[:entry] = v }
|
|
50
|
+
o.on("--timeout SEC", Float, "per-run timeout in seconds (default 120)") { |v| opts[:timeout] = v }
|
|
51
|
+
o.on("--format FORMAT", FORMATS, "text|json|speedscope (default text)") { |v| opts[:format] = v }
|
|
52
|
+
o.on("--output FILE", "write to FILE (default stdout)") { |v| opts[:output] = v }
|
|
53
|
+
o.on("--top N", Integer, "rows per ranking (default 10)") { |v| opts[:top] = v }
|
|
54
|
+
o.on("--no-suggestions", "do not print improvement suggestions") { opts[:suggestions] = false }
|
|
55
|
+
o.on("--initializer-threshold MS", Float, "app initializer threshold for suggestions (default 50)") { |v| opts[:initializer_threshold] = v }
|
|
56
|
+
o.on("--gem-threshold MS", Float, "gem self time threshold for suggestions (default 30)") { |v| opts[:gem_threshold] = v }
|
|
57
|
+
o.on("--redact", "replace home dir with ~, app root with <app>, gem paths with <gem:name> (all formats)") { opts[:redact] = true }
|
|
58
|
+
o.on("--version", "show version") { opts[:action] = :version }
|
|
59
|
+
o.on("-h", "--help", "show this help") { opts[:action] = :help }
|
|
60
|
+
end
|
|
61
|
+
rest = parser.parse(argv)
|
|
62
|
+
raise OptionParser::InvalidArgument, "--runs must be >= 1" if opts[:runs] < 1
|
|
63
|
+
raise OptionParser::InvalidArgument, "--top must be >= 1" if opts[:top] < 1
|
|
64
|
+
raise OptionParser::NeedlessArgument, rest[1..].join(" ") if rest.size > 1
|
|
65
|
+
@parser = parser
|
|
66
|
+
[opts, rest.first || Dir.pwd]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def top_help = "#{USAGE}\n hayaoki --version\n\nRun `hayaoki boot-report --help` for options."
|
|
72
|
+
|
|
73
|
+
def boot_report(argv)
|
|
74
|
+
opts, app_dir = begin
|
|
75
|
+
parse(argv)
|
|
76
|
+
rescue OptionParser::ParseError => e
|
|
77
|
+
@err.puts "hayaoki: #{e.message}\n#{USAGE}"
|
|
78
|
+
return 1
|
|
79
|
+
end
|
|
80
|
+
case opts[:action]
|
|
81
|
+
when :help then @out.puts(@parser.help); return 0
|
|
82
|
+
when :version then @out.puts("hayaoki #{Hayaoki::VERSION}"); return 0
|
|
83
|
+
end
|
|
84
|
+
unless File.directory?(app_dir)
|
|
85
|
+
@err.puts "hayaoki: APP_DIR not found: #{app_dir}"
|
|
86
|
+
return 1
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
require_relative "boot_report/runner"
|
|
90
|
+
require_relative "boot_report/aggregator"
|
|
91
|
+
runner = BootReport::Runner.new(app_dir: app_dir, runs: opts[:runs], env: opts[:env], bootsnap: opts[:bootsnap],
|
|
92
|
+
ruby: opts[:ruby], entry: opts[:entry], timeout: opts[:timeout], on_progress: progress_printer)
|
|
93
|
+
result = runner.run
|
|
94
|
+
text = render(result, opts, app_dir)
|
|
95
|
+
if opts[:output]
|
|
96
|
+
begin
|
|
97
|
+
File.write(opts[:output], text)
|
|
98
|
+
rescue SystemCallError => e
|
|
99
|
+
@err.puts "hayaoki: cannot write #{opts[:output]}: #{e.message}"
|
|
100
|
+
return 1
|
|
101
|
+
end
|
|
102
|
+
@err.puts "wrote #{opts[:output]}"
|
|
103
|
+
else
|
|
104
|
+
@out.write(text)
|
|
105
|
+
end
|
|
106
|
+
0
|
|
107
|
+
rescue BootReport::RunError => e
|
|
108
|
+
report_error(e, opts)
|
|
109
|
+
1
|
|
110
|
+
rescue Errno::ENOENT => e
|
|
111
|
+
@err.puts "hayaoki: could not start the child process (--ruby #{opts[:ruby].inspect}, APP_DIR #{app_dir.inspect}): #{e.message}"
|
|
112
|
+
@err.puts "Check that the --ruby executable exists and is on PATH."
|
|
113
|
+
1
|
|
114
|
+
rescue SystemCallError => e
|
|
115
|
+
@err.puts "hayaoki: #{e.message}"
|
|
116
|
+
1
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def render(result, opts, app_dir = Dir.pwd)
|
|
120
|
+
case opts[:format]
|
|
121
|
+
when "speedscope"
|
|
122
|
+
require_relative "boot_report/formatters/speedscope"
|
|
123
|
+
BootReport::Formatters::Speedscope.new.format(result)
|
|
124
|
+
else
|
|
125
|
+
reports = BootReport::Aggregator.new.aggregate_result(result)
|
|
126
|
+
if opts[:format] == "json"
|
|
127
|
+
require_relative "boot_report/formatters/json"
|
|
128
|
+
BootReport::Formatters::Json.new.with_suggestions(*suggest(result, reports, opts, app_dir)).format(reports)
|
|
129
|
+
else
|
|
130
|
+
require_relative "boot_report/formatters/text"
|
|
131
|
+
redact = opts[:redact] && begin
|
|
132
|
+
require_relative "boot_report/formatters/redact"
|
|
133
|
+
BootReport::Formatters::Redact.new(app_dir: app_dir).method(:apply)
|
|
134
|
+
end
|
|
135
|
+
BootReport::Formatters::Text.new(top: opts[:top], redact: redact).with_suggestions(*suggest(result, reports, opts, app_dir)).format(reports)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# [Suggestion の配列, 警告の配列]。--no-suggestions なら [nil, []] (formatter は節を出さない)。
|
|
141
|
+
def suggest(result, reports, opts, app_dir)
|
|
142
|
+
return [nil, []] if opts[:suggestions] == false
|
|
143
|
+
require_relative "boot_report/advisor"
|
|
144
|
+
kw = {}
|
|
145
|
+
kw[:initializer_threshold_ms] = opts[:initializer_threshold] if opts[:initializer_threshold]
|
|
146
|
+
kw[:gem_threshold_ms] = opts[:gem_threshold] if opts[:gem_threshold]
|
|
147
|
+
advisor = BootReport::Advisor.new(reports.values.first, app_dir: File.expand_path(app_dir), result: result, **kw)
|
|
148
|
+
[advisor.suggestions, advisor.warnings]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Runner の on_progress から stderr に 1 行ずつ出す。
|
|
152
|
+
def progress_printer
|
|
153
|
+
lambda do |ev|
|
|
154
|
+
case ev["phase"]
|
|
155
|
+
when :warmup then @err.puts "warmup (#{ev["bootsnap"]})..."
|
|
156
|
+
when :probe then @err.puts "run #{ev["index"]}/#{ev["total"]} (#{ev["bootsnap"]})..."
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
SECRET_HINT = /credentials|secret_key_base|master[ _.]?key|RAILS_MASTER_KEY/i
|
|
162
|
+
|
|
163
|
+
def report_error(e, opts = {})
|
|
164
|
+
case e.reason
|
|
165
|
+
when :app_dir, :no_lockfile, :interrupted, :ruby # 起動前の確認で止まったもの
|
|
166
|
+
@err.puts "hayaoki: #{e.message.lines.first.to_s.strip}"
|
|
167
|
+
return
|
|
168
|
+
end
|
|
169
|
+
@err.puts "hayaoki: boot failed (#{e.reason})"
|
|
170
|
+
@err.puts e.message.lines.first.to_s.strip
|
|
171
|
+
if e.probe_error
|
|
172
|
+
@err.puts "error: #{e.probe_error["class"]}: #{e.probe_error["message"]}"
|
|
173
|
+
end
|
|
174
|
+
if e.open_span
|
|
175
|
+
@err.puts "last open span: kind=#{e.open_span["kind"]} #{e.open_span["name"]}"
|
|
176
|
+
end
|
|
177
|
+
unless e.stderr_tail.strip.empty?
|
|
178
|
+
@err.puts "--- stderr (tail) ---"
|
|
179
|
+
@err.puts e.stderr_tail.lines.last(20).join
|
|
180
|
+
end
|
|
181
|
+
production_hint(e, opts)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# production で credentials / secret_key_base / master key 周りで落ちたときの案内。
|
|
185
|
+
def production_hint(e, opts)
|
|
186
|
+
return unless (opts[:env] || "production") == "production"
|
|
187
|
+
text = "#{e.stderr_tail}\n#{e.message}\n#{e.probe_error && e.probe_error["message"]}"
|
|
188
|
+
return unless text.match?(SECRET_HINT)
|
|
189
|
+
@err.puts "hint: booting with RAILS_ENV=production failed on credentials / secret_key_base / master key."
|
|
190
|
+
@err.puts " - set SECRET_KEY_BASE_DUMMY=1 (Rails 7.1+) or RAILS_MASTER_KEY in the environment, then re-run"
|
|
191
|
+
@err.puts " - or measure another environment with --env (e.g. --env staging)"
|
|
192
|
+
@err.puts " - note: the measurement boots your app with the production configuration, which may connect to external services"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# --redact: render の結果 (text / json / speedscope) のパスを伏せる。既存の render を書き換えずに差し込む。
|
|
198
|
+
module Hayaoki
|
|
199
|
+
module CLIRedact
|
|
200
|
+
private
|
|
201
|
+
|
|
202
|
+
def render(result, opts, app_dir = Dir.pwd)
|
|
203
|
+
text = super
|
|
204
|
+
return text unless opts[:redact]
|
|
205
|
+
require_relative "boot_report/formatters/redact"
|
|
206
|
+
BootReport::Formatters::Redact.new(app_dir: app_dir).apply(text)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
CLI.prepend(CLIRedact)
|
|
210
|
+
end
|