rails-profiler 0.34.4 → 0.34.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4912dd474a748e657c35111e3224d83500e75960ef2592e5cd652155dc62617
4
- data.tar.gz: d0d4cb0f34f25d98f5be08875bdc59ec4cc84894f7cbe0b512fd1ecd153dda4b
3
+ metadata.gz: 0637e842e7e8cce996b450d94201b5a117bc6797fe095a488ea9f0b9b54c504f
4
+ data.tar.gz: 5949fcd0e376266ebfff2cc698b4964c4af39f85ae83832e905c0909354844dc
5
5
  SHA512:
6
- metadata.gz: 3ca18ad3dd582e68c2bb935a5ace8628dc466a4999fa655e54117879548d543c5a291ec98765eb25456b0ff8fa774ee0f98f939544a346aa9b3368d37c421f72
7
- data.tar.gz: e144011d2fd93d37aa3166748bf2d3671d8d246325a475cf13d756b188dd99999ef6f95b232fd36eca444c91e24e2715c1902695697ea1930407bc932b2e952d
6
+ metadata.gz: a5834f8092fa5c9346fb578444a32e43ffb1ebe669ece132b8214d1400257a6dabcea588b3a81125924c40f64fd98aa410e92523500c829b7af06f128be42997
7
+ data.tar.gz: 4728ac7e13f397cf2439a079f63430d74056a9bf126c7cfb29001fcde35003b076b76426b600465a246c2a177f660600f9ae4e2deef445273cfb43464d09b398
data/CHANGELOG.md CHANGED
@@ -19,6 +19,53 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.34.6] - 2026-10-16
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Added
27
+
28
+ - **Test runner:** `config.test_runner_kill_grace_seconds` (5 by default),
29
+ `config.test_runner_max_output_bytes` (5 MB by default) and `config.test_runner_max_active_runs`
30
+ (2 by default), described under the fixes below. `nil` lifts either limit.
31
+
32
+ ### Fixed
33
+
34
+ - **Test runner:** A test process ended by a signal (killed for lack of memory, crashed, stopped by
35
+ another program) is no longer reported `passed` with exit code 0. The run ends `signaled`, with no
36
+ exit code, the name of the signal in a new `signal` field of the API, a `Signal` line in the MCP
37
+ tool `run_tests`, a `[Profiler]` line at the end of its output, and "Ended by a signal" in the page.
38
+ - **Test runner:** **Stop** now stops the whole test run. The test process runs in a process group
39
+ of its own: `TERM` goes to the whole group, the processes rspec or rails started included, and
40
+ `KILL` follows after `config.test_runner_kill_grace_seconds` for whatever ignored it. A stopped
41
+ run used to leave those processes, and a process ignoring `TERM`, running.
42
+ - **Test runner:** The output a run keeps is limited to `config.test_runner_max_output_bytes`: its
43
+ beginning, a `[Profiler] Output truncated` line, and its last quarter once the run ends, with the
44
+ number of bytes left out. It used to be kept whole, however large. At most 20 finished runs are
45
+ kept, on top of the one hour they were already kept.
46
+ - **Test runner:** Starting a run while `config.test_runner_max_active_runs` runs still have their
47
+ test process is refused, with a `429` from the API, the reason in the page, and a tool error from
48
+ `run_tests`. Any number of runs could be started at once.
49
+ - **Test runner:** A run is only known to the server process that started it. With Puma in cluster
50
+ mode, a request reaching another worker gets a `404` that now gives this reason; the
51
+ [test runner documentation](docs/testing.md#several-server-processes) describes the limit.
52
+
53
+ ## [0.34.5] - 2026-10-16
54
+
55
+ <!-- stamped -->
56
+
57
+ ### Fixed
58
+
59
+ - **Request capture:** the parameters of a request are now read once the application has returned, so
60
+ route parameters are present and `param_encoding` is applied, and they are converted to UTF-8. They
61
+ used to be read in the outermost middleware, before the router ran.
62
+ - **Request capture:** a text body keeps its accents. It is now read in the `charset` of its
63
+ `Content-Type`, UTF-8 when it declares none, and invalid bytes are replaced, instead of every
64
+ accent being turned into a replacement character.
65
+ - **Request capture:** a request whose `Content-Type` declares a charset Ruby cannot convert from
66
+ (`charset=utf-7`, `charset=internal`) no longer loses its profile; the body is read as UTF-8 with
67
+ invalid bytes replaced.
68
+
22
69
  ## [0.34.4] - 2026-10-16
23
70
 
24
71
  <!-- stamped -->
@@ -5311,7 +5311,7 @@
5311
5311
  if (status === "passed") return "var(--profiler-success, #10b981)";
5312
5312
  if (status === "failed") return "var(--profiler-error, #ef4444)";
5313
5313
  if (status === "running") return "var(--profiler-accent, #06b6d4)";
5314
- if (status === "killed" || status === "error") return "var(--profiler-warning, #f59e0b)";
5314
+ if (status === "killed" || status === "signaled" || status === "error") return "var(--profiler-warning, #f59e0b)";
5315
5315
  return "var(--profiler-text-muted, #888)";
5316
5316
  }
5317
5317
  function statusLabel(status) {
@@ -5321,6 +5321,7 @@
5321
5321
  passed: "\u2713 Passed",
5322
5322
  failed: "\u2717 Failed",
5323
5323
  killed: "\u25A0 Killed",
5324
+ signaled: "\u26A1 Ended by a signal",
5324
5325
  error: "\u26A0 Error"
5325
5326
  };
5326
5327
  return map[status] || status;
@@ -5430,7 +5431,7 @@
5430
5431
  }, [refetchFiles]);
5431
5432
  y2(() => {
5432
5433
  if (!currentRun || !isRunning) return;
5433
- if (["passed", "failed", "killed", "error"].includes(currentRun.status)) {
5434
+ if (["passed", "failed", "killed", "signaled", "error"].includes(currentRun.status)) {
5434
5435
  setIsRunning(false);
5435
5436
  return;
5436
5437
  }
@@ -5495,8 +5496,8 @@
5495
5496
  const data = await startTestRun({ data: { files: Array.from(selected), framework } });
5496
5497
  setCurrentRun(data);
5497
5498
  setIsRunning(true);
5498
- } catch {
5499
- setError("Failed to start test run");
5499
+ } catch (e3) {
5500
+ setError(e3 instanceof Error && e3.message ? e3.message : "Failed to start test run");
5500
5501
  }
5501
5502
  };
5502
5503
  const stopRun = async () => {
@@ -10,6 +10,10 @@ module Profiler
10
10
  # How long the page waits before asking for the output that came since.
11
11
  STREAM_RETRY_MS = 1000
12
12
 
13
+ # Runs live in the memory of the server process that started them.
14
+ RUN_NOT_FOUND = "Run not found. Runs are kept by the server process that started them: " \
15
+ "with several processes (Puma in cluster mode), follow a run from that process."
16
+
13
17
  def files
14
18
  framework = params[:framework]
15
19
  tree = Profiler::TestRunner::Discovery.files(framework: framework)
@@ -34,11 +38,13 @@ module Profiler
34
38
  render json: run.to_h, status: :created
35
39
  rescue Profiler::TestRunner::InvalidFileError => e
36
40
  render json: { error: e.message }, status: :unprocessable_entity
41
+ rescue Profiler::TestRunner::TooManyRunsError => e
42
+ render json: { error: e.message }, status: :too_many_requests
37
43
  end
38
44
 
39
45
  def show
40
46
  run = Profiler::TestRunner.run_store.find(params[:id])
41
- return render json: { error: "Run not found" }, status: :not_found unless run
47
+ return render json: { error: RUN_NOT_FOUND }, status: :not_found unless run
42
48
 
43
49
  render json: run.to_h
44
50
  end
@@ -51,7 +57,7 @@ module Profiler
51
57
  position = [(request.headers["Last-Event-ID"].presence || params[:position]).to_i, 0].max
52
58
  result = Profiler::TestRunner.run_store.read_output(params[:id], position: position)
53
59
  if result[:status] == "not_found"
54
- render json: { error: "Run not found" }, status: :not_found
60
+ render json: { error: RUN_NOT_FOUND }, status: :not_found
55
61
  return
56
62
  end
57
63
 
@@ -75,7 +81,8 @@ module Profiler
75
81
  if killed
76
82
  head :no_content
77
83
  else
78
- render json: { error: "Run not found or not running" }, status: :not_found
84
+ known = Profiler::TestRunner.run_store.find(params[:id])
85
+ render json: { error: known ? "Run not running" : RUN_NOT_FOUND }, status: :not_found
79
86
  end
80
87
  end
81
88
 
@@ -49,6 +49,8 @@ module Profiler
49
49
  :track_console,
50
50
  :apply_env_overrides_when_disabled, :restrict_storage_permissions,
51
51
  :test_runner_allow_undiscovered_files, :allow_explain_outside_development,
52
+ :test_runner_kill_grace_seconds,
53
+ :test_runner_max_output_bytes, :test_runner_max_active_runs,
52
54
  :track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
53
55
  :compress_bodies, :compress_body_threshold, :max_captured_body_bytes, :max_captured_log_bytes,
54
56
  :redact_sensitive_data, :filter_parameters, :env_allowlist,
@@ -113,6 +115,14 @@ module Profiler
113
115
  # only runs in the development and test environments, even with enabled = true elsewhere:
114
116
  # PostgreSQL's EXPLAIN ANALYZE runs the query. true allows it in every environment.
115
117
  @allow_explain_outside_development = false
118
+ # Stopping a run sends TERM to its whole process group, then KILL after this many seconds
119
+ # if any process of the group is still there.
120
+ @test_runner_kill_grace_seconds = 5
121
+ # What a run keeps of its output, in bytes: the beginning, then the last quarter of this
122
+ # once it is exceeded, with a marker in between. nil keeps everything.
123
+ @test_runner_max_output_bytes = 5 * 1024 * 1024
124
+ # Runs whose test process has not ended yet; another one is refused. nil for no limit.
125
+ @test_runner_max_active_runs = 2
116
126
  @track_mailers = true
117
127
  @capture_mail_body = false
118
128
  @sanitize_mailer_recipients = false
@@ -45,7 +45,7 @@ module Profiler
45
45
  run_started_at = Time.now
46
46
  begin
47
47
  run = Profiler::TestRunner::Runner.start(files: files, framework: framework)
48
- rescue Profiler::TestRunner::InvalidFileError => e
48
+ rescue Profiler::TestRunner::InvalidFileError, Profiler::TestRunner::TooManyRunsError => e
49
49
  return ::MCP::Tool::Response.new([{ type: "text", text: e.message }], error: true)
50
50
  end
51
51
 
@@ -108,7 +108,9 @@ module Profiler
108
108
  text += "| Field | Value |\n|-------|-------|\n"
109
109
  text += "| Run ID | `#{run_id}` |\n"
110
110
  text += "| Status | **#{run_data["status"]}** |\n"
111
- text += "| Exit code | #{run_data["exit_code"].inspect} |\n\n"
111
+ text += "| Exit code | #{run_data["exit_code"].inspect} |\n"
112
+ text += "| Signal | #{run_data["signal"]} |\n" if run_data["signal"]
113
+ text += "\n"
112
114
  text += "## Output\n```\n#{output.strip}\n```"
113
115
  [{ type: "text", text: text }]
114
116
  end
@@ -136,6 +138,7 @@ module Profiler
136
138
  lines << "| Files | #{files.size} |"
137
139
  lines << "| Status | **#{run.status}** |"
138
140
  lines << "| Exit code | #{run.exit_code.inspect} |"
141
+ lines << "| Signal | #{run.signal} |" if run.signal
139
142
  lines << "| Duration | #{run.to_h[:duration]&.round(0)}ms |"
140
143
 
141
144
  if timed_out
@@ -28,7 +28,7 @@ module Profiler
28
28
  kept = nil
29
29
  begin
30
30
  begin
31
- profile = Models::Profile.new(build_request(env))
31
+ profile = Models::Profile.new(build_request(env), params: false)
32
32
  profile.gem_version = Profiler::VERSION
33
33
  Profiler::CurrentContext.token = profile.token
34
34
 
@@ -57,10 +57,12 @@ module Profiler
57
57
  begin
58
58
  response = @app.call(env)
59
59
  rescue Exception => e # rubocop:disable Lint/RescueException
60
+ capture_params(env, profile) if failed_request?(e)
60
61
  record_failed_request(env, profile, collectors, request_body, allocations_before, e) if failed_request?(e)
61
62
  raise
62
63
  end
63
64
 
65
+ capture_params(env, profile)
64
66
  response, kept = complete_profile(env, profile, collectors, request_body, allocations_before, response)
65
67
  response
66
68
  ensure
@@ -74,6 +76,14 @@ module Profiler
74
76
 
75
77
  private
76
78
 
79
+ # Read after the application has run, so after the routing: the route parameters are there,
80
+ # and the params carry the encoding the route declared, as the controller saw them.
81
+ def capture_params(env, profile)
82
+ profile.capture_params(build_request(env))
83
+ rescue => e
84
+ Profiler.log_error("ProfilerMiddleware: could not read the request params", e)
85
+ end
86
+
77
87
  def complete_profile(env, profile, collectors, request_body, allocations_before, response)
78
88
  status, headers, body = response
79
89
 
@@ -22,7 +22,9 @@ module Profiler
22
22
  :collectors_released_after_seconds, :params_truncated,
23
23
  :gem_version
24
24
 
25
- def initialize(request = nil)
25
+ # +params+: false leaves the params to capture_params, for a caller that builds the profile
26
+ # before the router has run.
27
+ def initialize(request = nil, params: true)
26
28
  @token = SecureRandom.hex(16)
27
29
  @started_at = Time.now
28
30
  @collectors_data = {}
@@ -34,12 +36,19 @@ module Profiler
34
36
  if request
35
37
  @path = request.path
36
38
  @method = request.request_method
37
- @params, @params_truncated = cap_params(sanitize_params(request.params),
38
- Profiler.configuration.max_captured_body_bytes)
39
+ capture_params(request) if params
39
40
  @headers = extract_headers(request.env)
40
41
  end
41
42
  end
42
43
 
44
+ # The request's params as the controller sees them. Read once the router has run: Rails
45
+ # adds the route parameters (:id...) and applies the routes' param_encoding only then, so
46
+ # a read before it keeps neither.
47
+ def capture_params(request)
48
+ @params, @params_truncated = cap_params(sanitize_params(utf8_params(request.params)),
49
+ Profiler.configuration.max_captured_body_bytes)
50
+ end
51
+
43
52
  # The sizes are those of the whole bodies, when a body was cut at max_captured_body_bytes;
44
53
  # *_size_is_minimum when the whole size is not known (no Content-Length, a stream that
45
54
  # stopped), only that it is at least that.
@@ -252,7 +261,7 @@ module Profiler
252
261
  # compressed format (zip, png, gzip...) does not hold the secret as it is.
253
262
  { body: Base64.strict_encode64(Redaction.hide_credentials(raw.b)), encoding: "base64" }
254
263
  else
255
- text = Redaction.filter_body(raw, content_type).encode("UTF-8", invalid: :replace, undef: :replace)
264
+ text = utf8_text(Redaction.filter_body(raw, content_type), content_type)
256
265
  if compress_body?(text)
257
266
  # The fastest level: paid on the request, a quarter larger than the default level.
258
267
  { body: Base64.strict_encode64(Zlib::Deflate.deflate(text, Zlib::BEST_SPEED)), encoding: "gzip+base64" }
@@ -262,6 +271,39 @@ module Profiler
262
271
  end
263
272
  end
264
273
 
274
+ # The text as UTF-8: a body read from the wire is binary, and is taken for the charset its
275
+ # Content-Type declares (UTF-8 when it declares none, or one Ruby does not know). Bytes
276
+ # that do not belong to it are replaced, never raised on.
277
+ def utf8_text(text, content_type)
278
+ return text.encode("UTF-8", invalid: :replace, undef: :replace) unless text.encoding == Encoding::BINARY
279
+
280
+ charset = content_type.to_s[/charset\s*=\s*"?([^\s";]+)/i, 1]
281
+ source = begin
282
+ charset ? Encoding.find(charset) : Encoding::UTF_8
283
+ rescue ArgumentError
284
+ Encoding::UTF_8
285
+ end
286
+ # "internal" is a name Ruby knows but resolves to nil, "utf-7" is a dummy encoding with no converter.
287
+ source ||= Encoding::UTF_8
288
+ begin
289
+ text.dup.force_encoding(source).encode("UTF-8", invalid: :replace, undef: :replace)
290
+ rescue EncodingError
291
+ text.dup.force_encoding(Encoding::UTF_8).encode("UTF-8", invalid: :replace, undef: :replace)
292
+ end
293
+ end
294
+
295
+ # The params with their strings in UTF-8: a route's param_encoding gives some another
296
+ # encoding, which the profile must not carry into its JSON.
297
+ def utf8_params(value)
298
+ case value
299
+ when Hash then value.to_h { |k, v| [utf8_params(k), utf8_params(v)] }
300
+ when Array then value.map { |v| utf8_params(v) }
301
+ when String
302
+ value.encoding == Encoding::UTF_8 ? value : value.encode("UTF-8", invalid: :replace, undef: :replace)
303
+ else value
304
+ end
305
+ end
306
+
265
307
  def body_size(captured, total)
266
308
  captured_size = captured.to_s.bytesize
267
309
  total ||= captured_size
@@ -6,10 +6,18 @@ require_relative "../redaction"
6
6
 
7
7
  module Profiler
8
8
  module TestRunner
9
+ # Raised by RunStore#create when as many runs as allowed still have their test process.
10
+ class TooManyRunsError < StandardError; end
11
+
12
+ # The runs live in the memory of the process that started them: under a server running
13
+ # several processes (Puma in cluster mode), a run is only known to the worker that started it.
9
14
  class RunStore
10
15
  TTL = 3600 # 1 hour
16
+ # Finished runs kept at most, the oldest dropped first, on top of the TTL.
17
+ MAX_FINISHED_RUNS = 20
11
18
 
12
- Run = Struct.new(:id, :status, :pid, :started_at, :finished_at, :output_lines, :exit_code, :files, :framework, keyword_init: true) do
19
+ # signal: the name of the signal that ended the process ("SIGKILL"), nil when it exited.
20
+ Run = Struct.new(:id, :status, :pid, :started_at, :finished_at, :output_lines, :exit_code, :signal, :files, :framework, keyword_init: true) do
13
21
  def to_h
14
22
  super.merge(
15
23
  started_at: started_at&.iso8601,
@@ -20,7 +28,9 @@ module Profiler
20
28
  end
21
29
  end
22
30
 
23
- TERMINAL_STATUSES = %w[passed failed killed error].freeze
31
+ # signaled: the process was ended by a signal that did not come from the profiler (out of
32
+ # memory, a crash, another program), so the tests neither passed nor failed.
33
+ TERMINAL_STATUSES = %w[passed failed killed signaled error].freeze
24
34
  NOT_FOUND = { chunks: [].freeze, status: "not_found", position: 0, finished: true }.freeze
25
35
 
26
36
  def initialize
@@ -29,25 +39,51 @@ module Profiler
29
39
  @held = Concurrent::Hash.new
30
40
  @output_finished = Concurrent::Hash.new
31
41
  @held_lock = Mutex.new
42
+ @output_limits = Concurrent::Hash.new
43
+ @active = Concurrent::Hash.new
44
+ @create_lock = Mutex.new
32
45
  end
33
46
 
34
- def create(files:, framework:)
35
- id = SecureRandom.hex(8)
36
- run = Run.new(
37
- id: id,
38
- status: "pending",
39
- pid: nil,
40
- started_at: Time.now,
41
- finished_at: nil,
42
- output_lines: Concurrent::Array.new,
43
- exit_code: nil,
44
- files: files,
45
- framework: framework.to_s
46
- )
47
- @runs[id] = run
48
- @locks[id] = { mutex: Mutex.new, cond: ConditionVariable.new }
49
- cleanup_old_runs
50
- run
47
+ # max_active: the run is refused when that many runs still have their test process; a run
48
+ # counts from here until #release. max_output_bytes: what the run keeps of its output (see
49
+ # #keep_output). nil for no limit.
50
+ def create(files:, framework:, max_active: nil, max_output_bytes: nil)
51
+ @create_lock.synchronize do
52
+ if max_active && @active.size >= max_active
53
+ raise TooManyRunsError,
54
+ "#{@active.size} test runs are already running, the limit is #{max_active} " \
55
+ "(config.test_runner_max_active_runs). Wait for one to end, or stop it."
56
+ end
57
+
58
+ id = SecureRandom.hex(8)
59
+ run = Run.new(
60
+ id: id,
61
+ status: "pending",
62
+ pid: nil,
63
+ started_at: Time.now,
64
+ finished_at: nil,
65
+ output_lines: Concurrent::Array.new,
66
+ exit_code: nil,
67
+ signal: nil,
68
+ files: files,
69
+ framework: framework.to_s
70
+ )
71
+ cleanup_old_runs
72
+ @runs[id] = run
73
+ @locks[id] = { mutex: Mutex.new, cond: ConditionVariable.new }
74
+ @active[id] = true
75
+ if max_output_bytes
76
+ tail = max_output_bytes / 4
77
+ @output_limits[id] = { head: max_output_bytes - tail, tail: tail, kept: 0, after_head: 0, end: nil, end_bytes: 0 }
78
+ end
79
+ run
80
+ end
81
+ end
82
+
83
+ # The test process of the run ended: it no longer counts against max_active.
84
+ def release(id)
85
+ @active.delete(id)
86
+ nil
51
87
  end
52
88
 
53
89
  def find(id)
@@ -88,7 +124,7 @@ module Profiler
88
124
  cut = text.bytesize - holdback
89
125
  @held[id] = text.byteslice(cut, text.bytesize - cut)
90
126
  shown = as_text(text.byteslice(0, cut))
91
- run.output_lines.push(shown) unless shown.empty?
127
+ keep_output(id, run, shown) unless shown.empty?
92
128
  end
93
129
  signal(id)
94
130
  end
@@ -102,7 +138,8 @@ module Profiler
102
138
  @held_lock.synchronize do
103
139
  @output_finished[id] = true
104
140
  rest = @held.delete(id)
105
- run.output_lines.push(as_text(rest)) if rest && !rest.empty?
141
+ keep_output(id, run, as_text(rest)) if rest && !rest.empty?
142
+ release_tail(id, run)
106
143
  end
107
144
  signal(id)
108
145
  end
@@ -171,10 +208,64 @@ module Profiler
171
208
  bytes.dup.force_encoding(Encoding::UTF_8).scrub
172
209
  end
173
210
 
174
- def forget_output(id)
211
+ # Under a limit, the output is kept as it comes up to the head (the limit less a quarter),
212
+ # then a marker line is shown at once and only the last quarter is kept, in a buffer
213
+ # shown by #release_tail once the process printed everything: the summary of a test run is
214
+ # at its end. Lines only ever get added, so the positions the pages hold stay right.
215
+ # Called under @held_lock.
216
+ def keep_output(id, run, text)
217
+ limit = @output_limits[id]
218
+ return run.output_lines.push(text) unless limit
219
+
220
+ unless limit[:end]
221
+ room = limit[:head] - limit[:kept]
222
+ if text.bytesize <= room
223
+ limit[:kept] += text.bytesize
224
+ return run.output_lines.push(text)
225
+ end
226
+
227
+ # What fits, without the first bytes of a character the limit cuts in two.
228
+ head = text.byteslice(0, room).scrub("")
229
+ run.output_lines.push(head) unless head.empty?
230
+ run.output_lines.push("\n[Profiler] Output truncated: the run printed more than " \
231
+ "#{limit[:head]} bytes. The rest is not kept; its last " \
232
+ "#{limit[:tail]} bytes are shown once the run ends.\n")
233
+ limit[:kept] += head.bytesize
234
+ limit[:end] = []
235
+ text = text.byteslice(head.bytesize, text.bytesize - head.bytesize)
236
+ end
237
+
238
+ # The pieces of the end, the oldest dropped while the others hold enough without it.
239
+ limit[:after_head] += text.bytesize
240
+ limit[:end] << text
241
+ limit[:end_bytes] += text.bytesize
242
+ while limit[:end_bytes] - limit[:end].first.bytesize >= limit[:tail]
243
+ limit[:end_bytes] -= limit[:end].shift.bytesize
244
+ end
245
+ end
246
+
247
+ # Shows the end kept past the limit, and lets anything appended later (the profiler's own
248
+ # lines about the end of the process) through.
249
+ def release_tail(id, run)
250
+ limit = @output_limits.delete(id)
251
+ return unless limit && limit[:end]
252
+
253
+ tail = limit[:end].join
254
+ tail = tail.byteslice(tail.bytesize - limit[:tail], limit[:tail]) if tail.bytesize > limit[:tail]
255
+ # Dropping from the front can leave the last bytes of a character first.
256
+ tail = tail.byteslice(1, tail.bytesize - 1) while !tail.empty? && (tail.getbyte(0) & 0xC0) == 0x80
257
+ left_out = limit[:after_head] - tail.bytesize
258
+ run.output_lines.push("[Profiler] #{left_out} bytes left out. The last #{tail.bytesize} bytes " \
259
+ "of the output:\n")
260
+ run.output_lines.push(tail.scrub) unless tail.empty?
261
+ end
262
+
263
+ def forget(id)
264
+ @runs.delete(id)
265
+ @locks.delete(id)
175
266
  @held.delete(id)
176
267
  @output_finished.delete(id)
177
- true
268
+ @output_limits.delete(id)
178
269
  end
179
270
 
180
271
  def snapshot(run, position)
@@ -187,9 +278,13 @@ module Profiler
187
278
  }
188
279
  end
189
280
 
281
+ # Finished runs older than the TTL, and the oldest past MAX_FINISHED_RUNS, are dropped; a
282
+ # run whose process is still there (killed, still printing) is kept whatever its age.
190
283
  def cleanup_old_runs
191
284
  cutoff = Time.now - TTL
192
- @runs.delete_if { |id, r| r.finished_at && r.finished_at < cutoff && @locks.delete(id) && forget_output(id) }
285
+ finished = @runs.values.select { |r| r.finished_at && !@active.key?(r.id) }.sort_by(&:finished_at)
286
+ expired, recent = finished.partition { |r| r.finished_at < cutoff }
287
+ (expired + recent.first([recent.size - MAX_FINISHED_RUNS, 0].max)).each { |r| forget(r.id) }
193
288
  end
194
289
  end
195
290
 
@@ -21,24 +21,49 @@ module Profiler
21
21
 
22
22
  # Every caller (the HTTP controller, the MCP run_tests tool) goes through here, so the
23
23
  # selection is checked once, before any run is recorded or any process is started.
24
+ # Raises TooManyRunsError when config.test_runner_max_active_runs runs still have their
25
+ # test process.
24
26
  def self.start(files:, framework:)
25
27
  validate_files!(files, framework)
26
- run = Profiler::TestRunner.run_store.create(files: files, framework: framework)
28
+ config = Profiler.configuration
29
+ run = Profiler::TestRunner.run_store.create(
30
+ files: files, framework: framework,
31
+ max_active: config.test_runner_max_active_runs,
32
+ max_output_bytes: config.test_runner_max_output_bytes
33
+ )
27
34
  spawn_async(run)
28
35
  run
29
36
  end
30
37
 
38
+ # The test process leads its own process group (see run_process): TERM goes to the whole
39
+ # group, the processes rspec or rails started included, and KILL follows after
40
+ # config.test_runner_kill_grace_seconds for whatever is still there.
31
41
  def self.kill(run_id)
32
42
  run = Profiler::TestRunner.run_store.find(run_id)
33
43
  return false unless run && run.pid && run.status == "running"
34
44
 
35
45
  begin
36
- Process.kill("TERM", run.pid)
37
- Profiler::TestRunner.run_store.update(run_id, status: "killed", finished_at: Time.now)
38
- true
46
+ Process.kill("TERM", -run.pid)
39
47
  rescue Errno::ESRCH
40
48
  # Process already exited
41
- false
49
+ return false
50
+ end
51
+ Profiler::TestRunner.run_store.update(run_id, status: "killed", finished_at: Time.now)
52
+ kill_group_later(run.pid)
53
+ true
54
+ end
55
+
56
+ # The group exists as long as one of its processes does. Its id is the pid of the test
57
+ # process, which the system can give to a new process once the whole group is gone; that
58
+ # takes the pids to wrap around within the delay, and the new process would have to lead a
59
+ # group of its own.
60
+ def self.kill_group_later(pgid)
61
+ delay = Profiler.configuration.test_runner_kill_grace_seconds.to_f
62
+ Thread.new do
63
+ sleep delay
64
+ Process.kill("KILL", -pgid)
65
+ rescue Errno::ESRCH, Errno::EPERM
66
+ nil # the group ended on TERM
42
67
  end
43
68
  end
44
69
 
@@ -123,6 +148,8 @@ module Profiler
123
148
  status: "error",
124
149
  finished_at: Time.now
125
150
  )
151
+ ensure
152
+ Profiler::TestRunner.run_store.release(run.id)
126
153
  end
127
154
  end
128
155
 
@@ -132,8 +159,9 @@ module Profiler
132
159
 
133
160
  Profiler::TestRunner.run_store.update(run.id, status: "running", started_at: Time.now)
134
161
 
135
- # unsetenv_others: the child gets exactly env, not the ENV of this process
136
- IO.popen([env, *cmd, unsetenv_others: true, err: [:child, :out]], "r") do |io|
162
+ # unsetenv_others: the child gets exactly env, not the ENV of this process. pgroup: the
163
+ # child leads a process group of its own, which kill stops as a whole.
164
+ IO.popen([env, *cmd, unsetenv_others: true, pgroup: true, err: [:child, :out]], "r") do |io|
137
165
  Profiler::TestRunner.run_store.update(run.id, pid: io.pid)
138
166
 
139
167
  begin
@@ -148,21 +176,38 @@ module Profiler
148
176
  end
149
177
  end
150
178
 
151
- exit_code = $?.exitstatus || 0
179
+ process_status = $?
180
+ exit_code = process_status.exitstatus # nil when a signal ended the process
181
+ signal = signal_name(process_status.termsig) if process_status.signaled?
152
182
  # A run killed from the profiler stays killed, whatever the process answered to TERM.
153
183
  status = if run.status == "killed" then "killed"
184
+ elsif signal then "signaled"
154
185
  elsif exit_code == 0 then "passed"
155
186
  else "failed"
156
187
  end
157
188
 
189
+ if status == "signaled"
190
+ # Before the status: a page following the run stops at its terminal status.
191
+ Profiler::TestRunner.run_store.append_output(
192
+ run.id, "\n[Profiler] The test process was ended by #{signal} before it finished: " \
193
+ "the result of the tests is unknown.\n"
194
+ )
195
+ end
196
+
158
197
  Profiler::TestRunner.run_store.update(
159
198
  run.id,
160
199
  status: status,
161
200
  finished_at: Time.now,
162
- exit_code: exit_code
201
+ exit_code: exit_code,
202
+ signal: signal
163
203
  )
164
204
  end
165
205
 
206
+ def self.signal_name(number)
207
+ name = Signal.signame(number)
208
+ name ? "SIG#{name}" : "signal #{number}"
209
+ end
210
+
166
211
  def self.build_command(files, framework)
167
212
  root = project_root
168
213
  absolute_files = files.map { |f| File.join(root, f) }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.34.4"
4
+ VERSION = "0.34.6"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.34.4
4
+ version: 0.34.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy