rails-profiler 0.34.5 → 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 +4 -4
- data/CHANGELOG.md +31 -0
- data/app/assets/builds/profiler.js +5 -4
- data/app/controllers/profiler/api/test_runner_controller.rb +10 -3
- data/lib/profiler/configuration.rb +10 -0
- data/lib/profiler/mcp/tools/run_tests.rb +5 -2
- data/lib/profiler/test_runner/run_store.rb +119 -24
- data/lib/profiler/test_runner/runner.rb +54 -9
- data/lib/profiler/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0637e842e7e8cce996b450d94201b5a117bc6797fe095a488ea9f0b9b54c504f
|
|
4
|
+
data.tar.gz: 5949fcd0e376266ebfff2cc698b4964c4af39f85ae83832e905c0909354844dc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a5834f8092fa5c9346fb578444a32e43ffb1ebe669ece132b8214d1400257a6dabcea588b3a81125924c40f64fd98aa410e92523500c829b7af06f128be42997
|
|
7
|
+
data.tar.gz: 4728ac7e13f397cf2439a079f63430d74056a9bf126c7cfb29001fcde35003b076b76426b600465a246c2a177f660600f9ae4e2deef445273cfb43464d09b398
|
data/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,37 @@ 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
|
+
|
|
22
53
|
## [0.34.5] - 2026-10-16
|
|
23
54
|
|
|
24
55
|
<!-- 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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) }
|
data/lib/profiler/version.rb
CHANGED