rails-profiler 0.30.13 → 0.30.15
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 +65 -0
- data/app/assets/builds/profiler-toolbar.js +96 -31
- data/app/assets/builds/profiler.js +1 -1
- data/app/controllers/profiler/api/events_controller.rb +12 -23
- data/app/controllers/profiler/api/test_runner_controller.rb +22 -36
- data/app/controllers/profiler/api/toolbar_controller.rb +7 -1
- data/config/routes.rb +1 -1
- data/lib/profiler/configuration.rb +29 -3
- data/lib/profiler/instrumentation/active_job_instrumentation.rb +3 -2
- data/lib/profiler/mcp/tools/run_tests.rb +3 -2
- data/lib/profiler/railtie.rb +40 -15
- data/lib/profiler/sse/event_bus.rb +28 -27
- data/lib/profiler/sse/redis_event_bus.rb +25 -57
- data/lib/profiler/storage/base_store.rb +5 -5
- data/lib/profiler/test_runner/run_store.rb +49 -6
- data/lib/profiler/test_runner/runner.rb +8 -3
- data/lib/profiler/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2acc29c9f259551473810464771039fd61748d4387892bb42eda70cbe78648de
|
|
4
|
+
data.tar.gz: d47575f34e2dd8ad24e6290c2633a2a5d57e6591439eb954ce3d74f60ea17749
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 784d7a77e084ffc3ffb1c4664f3e6519323146a1a40a1cd783b2144950ede1129702b1e32e4b5583449bc8786d438fcdf2b943730aecdc2d5c95bbc4944309f2
|
|
7
|
+
data.tar.gz: 0d9d5aa7631bfc6459a5f66a7f9f181093a4365c4459ac27423d378532fad2b6858586a3b565bf985719f60aedb3a75aab5a47b86bd28ff3bbf36ce5c1280366
|
data/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,71 @@ every commit of every tag interval is accounted for one way or the other.
|
|
|
19
19
|
|
|
20
20
|
## [Unreleased]
|
|
21
21
|
|
|
22
|
+
## [0.30.15] - 2026-10-05
|
|
23
|
+
|
|
24
|
+
<!-- stamped -->
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **Toolbar:** Stop the toolbar from holding a server thread for as long as its page stays open.
|
|
29
|
+
Every page kept a server-sent events stream open on `/_profiler/api/events/:token`, and the
|
|
30
|
+
server waited on it in one of its threads, 30 seconds at a time; a closed page was only noticed
|
|
31
|
+
at the next 30-second heartbeat. With as many open (or recently closed) pages as the server has
|
|
32
|
+
threads (3 in the Puma configuration Rails generates), the application stopped answering. The toolbar now asks whether
|
|
33
|
+
its profile was saved again with short requests the server answers at once: after 1 s, then
|
|
34
|
+
less and less often (10 in the first minute, 2 a minute after that), none while the tab is
|
|
35
|
+
hidden, and none after 10 minutes without a save. `GET /_profiler/api/events/:token` now
|
|
36
|
+
answers JSON (`cursor`, `updated`) for a `since` version, and the toolbar data carries the
|
|
37
|
+
version it reflects as `events_cursor`. With Redis storage, saves are counted in Redis: the
|
|
38
|
+
bus no longer keeps a listening thread and a dedicated Redis connection in each process, and a
|
|
39
|
+
failing Redis no longer leaves every toolbar without updates until the next page.
|
|
40
|
+
- **Test runner:** Follow the output of a run live again on `/_profiler/test_runner`. The output
|
|
41
|
+
stream answered `500` on every request since server-sent events were added for the toolbar
|
|
42
|
+
(its controller picked up the wrong `SSE` class), so the page showed the output as it was when
|
|
43
|
+
it started following and then stopped. The stream now answers at once with the output there
|
|
44
|
+
is and the browser asks again every second, with the position it reached, while the run is in
|
|
45
|
+
progress: following a run holds no server thread either.
|
|
46
|
+
- **Test runner:** Show the output of a run as UTF-8 text. The process is read 256 bytes at a
|
|
47
|
+
time, so a character could be cut between two pieces: the output stream then failed on every
|
|
48
|
+
request, the run's JSON (`GET /_profiler/api/test_runner/runs/:id`, which `run_tests` reads on a
|
|
49
|
+
slave) warned or failed, and non-ASCII output was labelled binary. The first bytes of a cut
|
|
50
|
+
character now wait for the next piece, and bytes that are not UTF-8 show as `U+FFFD`.
|
|
51
|
+
- **Test runner:** Keep a killed run `killed`, and send its end to the page only with its last
|
|
52
|
+
output. The status of a killed run became `passed` or `failed` once the process exited, and the
|
|
53
|
+
page following a killed run, or a run whose test command failed to start, could stop before
|
|
54
|
+
the summary or the `[Profiler] Error:` line.
|
|
55
|
+
- **MCP:** `run_tests` with a `slave` (FAB-20) returns as soon as the run on the slave is over, with
|
|
56
|
+
its output. It waited for statuses the test runner never gives (`completed`, `cancelled`), so a
|
|
57
|
+
run that passed was reported as timed out after `timeout_seconds` (120 by default), holding a
|
|
58
|
+
server thread of the master all that time, and it read the output from a field the slave does
|
|
59
|
+
not send, so the output was always empty.
|
|
60
|
+
|
|
61
|
+
## [0.30.14] - 2026-10-05
|
|
62
|
+
|
|
63
|
+
<!-- stamped -->
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- **Railtie:** Honor `config.enabled` set in `config/initializers/profiler.rb`. The profiler
|
|
68
|
+
decided before the application's initializers had run, so `enabled = false` there still put its
|
|
69
|
+
middlewares in the stack, configured Sidekiq, included its ActiveJob instrumentation and, in
|
|
70
|
+
the test environment, loaded the test profiler, and `enabled = true` in production got none of
|
|
71
|
+
them. Those decisions are now taken once the application's initializers have run. An active
|
|
72
|
+
profiler keeps its place in the middleware stack, and its Sidekiq middlewares and ActiveJob
|
|
73
|
+
callbacks stay ahead of the ones the application installs. The profiler is now the outermost
|
|
74
|
+
layer around a job: it runs before every other Sidekiq middleware, including those of gems
|
|
75
|
+
loaded before it and those the application prepends (such as `Sidekiq::CurrentAttributes`),
|
|
76
|
+
and before the callbacks Rails adds to ActiveJob (logging, instrumentation, and on Rails 7.0
|
|
77
|
+
time zone and locale), so the duration of a profiled job now includes them.
|
|
78
|
+
- **Railtie:** Keep the `enabled`, `storage` and `track_tests` values set with
|
|
79
|
+
`Profiler.configure` in `config/application.rb`, which the Rails defaults overwrote at boot, and
|
|
80
|
+
apply `config.profiler` (`config.profiler.enabled = false` in `config/application.rb`), which was
|
|
81
|
+
accepted and never read. A `config.profiler` key that is not a profiler option logs a warning.
|
|
82
|
+
`config/initializers/profiler.rb` still has the last word. Check these places when upgrading:
|
|
83
|
+
an `enabled` set with `Profiler.configure` in `config/application.rb` or in a
|
|
84
|
+
`config/environments` file, or with `config.profiler.enabled`, now takes effect, in production
|
|
85
|
+
too, where it was overwritten or ignored before.
|
|
86
|
+
|
|
22
87
|
## [0.30.13] - 2026-10-04
|
|
23
88
|
|
|
24
89
|
<!-- stamped -->
|
|
@@ -4210,43 +4210,108 @@
|
|
|
4210
4210
|
return query;
|
|
4211
4211
|
}
|
|
4212
4212
|
|
|
4213
|
+
// app/assets/typescript/profiler/hooks/profileUpdatePoller.ts
|
|
4214
|
+
var POLL_DELAYS_MS = [1e3, 1e3, 2e3, 2e3, 4e3, 4e3, 8e3, 8e3, 15e3, 15e3];
|
|
4215
|
+
var STEADY_DELAY_MS = 3e4;
|
|
4216
|
+
var GIVE_UP_AFTER_MS = 10 * 6e4;
|
|
4217
|
+
function isValidAnswer(answer) {
|
|
4218
|
+
return typeof answer === "object" && answer !== null && Number.isFinite(answer.cursor);
|
|
4219
|
+
}
|
|
4220
|
+
function startProfileUpdatePoller(options) {
|
|
4221
|
+
const now = options.now ?? (() => Date.now());
|
|
4222
|
+
const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
4223
|
+
const clearTimer = options.clearTimer ?? ((timer2) => clearTimeout(timer2));
|
|
4224
|
+
let cursor = options.cursor;
|
|
4225
|
+
let step = 0;
|
|
4226
|
+
let lastSaveAt = now();
|
|
4227
|
+
let timer = null;
|
|
4228
|
+
let paused = false;
|
|
4229
|
+
let stopped = false;
|
|
4230
|
+
let inFlight = false;
|
|
4231
|
+
const cancelTimer = () => {
|
|
4232
|
+
if (timer !== null) {
|
|
4233
|
+
clearTimer(timer);
|
|
4234
|
+
timer = null;
|
|
4235
|
+
}
|
|
4236
|
+
};
|
|
4237
|
+
const scheduleNext = () => {
|
|
4238
|
+
cancelTimer();
|
|
4239
|
+
if (stopped || paused) return;
|
|
4240
|
+
if (now() - lastSaveAt >= GIVE_UP_AFTER_MS) return;
|
|
4241
|
+
const delay = step < POLL_DELAYS_MS.length ? POLL_DELAYS_MS[step] : STEADY_DELAY_MS;
|
|
4242
|
+
step += 1;
|
|
4243
|
+
timer = setTimer(() => {
|
|
4244
|
+
timer = null;
|
|
4245
|
+
void checkNow();
|
|
4246
|
+
}, delay);
|
|
4247
|
+
};
|
|
4248
|
+
const checkNow = async () => {
|
|
4249
|
+
if (stopped || inFlight) return;
|
|
4250
|
+
inFlight = true;
|
|
4251
|
+
try {
|
|
4252
|
+
const answer = await options.check(cursor);
|
|
4253
|
+
if (stopped) return;
|
|
4254
|
+
if (isValidAnswer(answer)) {
|
|
4255
|
+
cursor = Math.max(cursor, answer.cursor);
|
|
4256
|
+
if (answer.updated === true) {
|
|
4257
|
+
step = 0;
|
|
4258
|
+
lastSaveAt = now();
|
|
4259
|
+
options.onUpdate();
|
|
4260
|
+
}
|
|
4261
|
+
}
|
|
4262
|
+
} catch {
|
|
4263
|
+
} finally {
|
|
4264
|
+
inFlight = false;
|
|
4265
|
+
}
|
|
4266
|
+
scheduleNext();
|
|
4267
|
+
};
|
|
4268
|
+
scheduleNext();
|
|
4269
|
+
return {
|
|
4270
|
+
pause() {
|
|
4271
|
+
paused = true;
|
|
4272
|
+
cancelTimer();
|
|
4273
|
+
},
|
|
4274
|
+
resume() {
|
|
4275
|
+
if (stopped) return;
|
|
4276
|
+
paused = false;
|
|
4277
|
+
cancelTimer();
|
|
4278
|
+
void checkNow();
|
|
4279
|
+
},
|
|
4280
|
+
stop() {
|
|
4281
|
+
stopped = true;
|
|
4282
|
+
cancelTimer();
|
|
4283
|
+
}
|
|
4284
|
+
};
|
|
4285
|
+
}
|
|
4286
|
+
|
|
4213
4287
|
// app/assets/typescript/profiler/hooks/useProfileEvents.ts
|
|
4214
4288
|
var BASE = "/_profiler";
|
|
4215
|
-
function useProfileEvents(token,
|
|
4216
|
-
const
|
|
4217
|
-
|
|
4289
|
+
function useProfileEvents(token, cursor, onUpdate) {
|
|
4290
|
+
const onUpdateRef = A2(onUpdate);
|
|
4291
|
+
onUpdateRef.current = onUpdate;
|
|
4292
|
+
const ready = cursor !== void 0;
|
|
4218
4293
|
y2(() => {
|
|
4219
|
-
if (!token) return;
|
|
4220
|
-
const
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
const url = `${BASE}/api/events/${token}?${params.toString()}`;
|
|
4229
|
-
const es = new EventSource(url);
|
|
4230
|
-
es.addEventListener("profile_update", () => {
|
|
4231
|
-
onUpdate();
|
|
4294
|
+
if (!token || cursor === void 0) return;
|
|
4295
|
+
const poller = startProfileUpdatePoller({
|
|
4296
|
+
cursor,
|
|
4297
|
+
check: (since) => apiFetch({
|
|
4298
|
+
url: `${BASE}/api/events/${encodeURIComponent(token)}`,
|
|
4299
|
+
method: "GET",
|
|
4300
|
+
params: { since }
|
|
4301
|
+
}),
|
|
4302
|
+
onUpdate: () => onUpdateRef.current()
|
|
4232
4303
|
});
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
};
|
|
4237
|
-
es.onerror = () => {
|
|
4238
|
-
es.close();
|
|
4239
|
-
setConnected(false);
|
|
4240
|
-
if (fallbackRef.current === null) {
|
|
4241
|
-
fallbackRef.current = setInterval(() => onUpdate(), 1e4);
|
|
4242
|
-
}
|
|
4304
|
+
const onVisibilityChange = () => {
|
|
4305
|
+
if (document.hidden) poller.pause();
|
|
4306
|
+
else poller.resume();
|
|
4243
4307
|
};
|
|
4308
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
4309
|
+
if (document.hidden) poller.pause();
|
|
4244
4310
|
return () => {
|
|
4245
|
-
|
|
4246
|
-
|
|
4311
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
4312
|
+
poller.stop();
|
|
4247
4313
|
};
|
|
4248
|
-
}, [token]);
|
|
4249
|
-
return { connected };
|
|
4314
|
+
}, [token, ready]);
|
|
4250
4315
|
}
|
|
4251
4316
|
|
|
4252
4317
|
// app/assets/typescript/profiler/toolbar-bundle.tsx
|
|
@@ -4266,7 +4331,7 @@
|
|
|
4266
4331
|
refetch();
|
|
4267
4332
|
};
|
|
4268
4333
|
}, [refetch]);
|
|
4269
|
-
useProfileEvents(token,
|
|
4334
|
+
useProfileEvents(token, data?.events_cursor, () => {
|
|
4270
4335
|
refetch();
|
|
4271
4336
|
});
|
|
4272
4337
|
const profile = data?.profile ?? null;
|
|
@@ -5418,7 +5418,7 @@
|
|
|
5418
5418
|
es.close();
|
|
5419
5419
|
});
|
|
5420
5420
|
es.onerror = () => {
|
|
5421
|
-
es.
|
|
5421
|
+
if (es.readyState !== EventSource.CLOSED) return;
|
|
5422
5422
|
fetch(`${BASE}/api/test_runner/runs/${currentRun.id}`).then((r3) => r3.json()).then((data) => {
|
|
5423
5423
|
setCurrentRun(data);
|
|
5424
5424
|
setIsRunning(false);
|
|
@@ -2,32 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
module Profiler
|
|
4
4
|
module Api
|
|
5
|
+
# Tells the toolbar whether its profile was saved again since the version it holds. Answered
|
|
6
|
+
# at once: the toolbar asks again later, so no server thread waits for a save that may never
|
|
7
|
+
# come, however many pages are open.
|
|
5
8
|
class EventsController < Profiler::ApplicationController
|
|
6
|
-
|
|
9
|
+
VERSION_FORMAT = /\A\d+\z/
|
|
7
10
|
|
|
8
|
-
def
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
def show
|
|
12
|
+
cursor = Profiler::SSE.current.version(params[:token])
|
|
13
|
+
since = params[:since].to_s
|
|
14
|
+
# Without a version (none sent, NaN, anything but digits) the caller holds none: it gets
|
|
15
|
+
# the current one and no update, rather than an update on every check.
|
|
16
|
+
return render json: { cursor: cursor, updated: false } unless VERSION_FORMAT.match?(since)
|
|
12
17
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
begin
|
|
17
|
-
loop do
|
|
18
|
-
event = Profiler::SSE.current.wait_for_event(id, timeout: 30)
|
|
19
|
-
if event
|
|
20
|
-
sse.write(event)
|
|
21
|
-
else
|
|
22
|
-
sse.write({}, event: "heartbeat")
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
rescue ActionController::Live::ClientDisconnected, IOError
|
|
26
|
-
# Client disconnected — normal exit
|
|
27
|
-
ensure
|
|
28
|
-
Profiler::SSE.current.unsubscribe(id)
|
|
29
|
-
sse.close
|
|
30
|
-
end
|
|
18
|
+
since = since.to_i
|
|
19
|
+
render json: { cursor: [cursor, since].max, updated: cursor > since }
|
|
31
20
|
end
|
|
32
21
|
end
|
|
33
22
|
end
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "stringio"
|
|
3
4
|
require "profiler/test_runner/discovery"
|
|
4
5
|
require "profiler/test_runner/runner"
|
|
5
6
|
|
|
6
7
|
module Profiler
|
|
7
8
|
module Api
|
|
8
9
|
class TestRunnerController < ApplicationController
|
|
9
|
-
|
|
10
|
+
# How long the page waits before asking for the output that came since.
|
|
11
|
+
STREAM_RETRY_MS = 1000
|
|
10
12
|
|
|
11
13
|
def files
|
|
12
14
|
framework = params[:framework]
|
|
@@ -41,47 +43,31 @@ module Profiler
|
|
|
41
43
|
render json: run.to_h
|
|
42
44
|
end
|
|
43
45
|
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
+
# Server-sent events with the output of a run from the position the page holds: what is
|
|
47
|
+
# there now, then the response ends. The page's EventSource comes back after `retry`, with
|
|
48
|
+
# the position reached in Last-Event-ID, so no server thread waits on a run that prints
|
|
49
|
+
# nothing, however many pages follow it.
|
|
46
50
|
def stream
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
position = [(request.headers["Last-Event-ID"].presence || params[:position]).to_i, 0].max
|
|
52
|
+
result = Profiler::TestRunner.run_store.read_output(params[:id], position: position)
|
|
53
|
+
if result[:status] == "not_found"
|
|
49
54
|
render json: { error: "Run not found" }, status: :not_found
|
|
50
55
|
return
|
|
51
56
|
end
|
|
52
57
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
result = Profiler::TestRunner.run_store.wait_for_output(
|
|
63
|
-
params[:id], position: position, timeout: 15
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
result[:chunks].each do |chunk|
|
|
67
|
-
sse.write({ chunk: chunk })
|
|
68
|
-
end
|
|
69
|
-
position = result[:position]
|
|
70
|
-
|
|
71
|
-
if result[:finished]
|
|
72
|
-
current_run = Profiler::TestRunner.run_store.find(params[:id])
|
|
73
|
-
sse.write(
|
|
74
|
-
{ status: result[:status], exit_code: current_run&.exit_code },
|
|
75
|
-
event: "done"
|
|
76
|
-
)
|
|
77
|
-
break
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
rescue ActionController::Live::ClientDisconnected, IOError
|
|
81
|
-
# Client navigated away — normal exit
|
|
82
|
-
ensure
|
|
83
|
-
sse.close
|
|
58
|
+
body = StringIO.new
|
|
59
|
+
body.write("retry: #{STREAM_RETRY_MS}\n\n")
|
|
60
|
+
sse = ActionController::Live::SSE.new(body, event: "output")
|
|
61
|
+
result[:chunks].each.with_index(position + 1) do |chunk, id|
|
|
62
|
+
sse.write({ chunk: chunk }, id: id)
|
|
63
|
+
end
|
|
64
|
+
if result[:finished]
|
|
65
|
+
run = Profiler::TestRunner.run_store.find(params[:id])
|
|
66
|
+
sse.write({ status: result[:status], exit_code: run&.exit_code }, event: "done")
|
|
84
67
|
end
|
|
68
|
+
|
|
69
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
70
|
+
render plain: body.string, content_type: "text/event-stream"
|
|
85
71
|
end
|
|
86
72
|
|
|
87
73
|
def destroy
|
|
@@ -4,6 +4,9 @@ module Profiler
|
|
|
4
4
|
module Api
|
|
5
5
|
class ToolbarController < Profiler::ApplicationController
|
|
6
6
|
def show
|
|
7
|
+
# Read before the profile: a save landing in between makes the toolbar fetch it again
|
|
8
|
+
# rather than miss it.
|
|
9
|
+
events_cursor = Profiler::SSE.current.version(params[:token])
|
|
7
10
|
profile = Profiler.storage.load(params[:token])
|
|
8
11
|
|
|
9
12
|
unless profile
|
|
@@ -14,7 +17,10 @@ module Profiler
|
|
|
14
17
|
# Recalculate AJAX collector data (since AJAX requests happen after page load)
|
|
15
18
|
recalculate_ajax_data(profile)
|
|
16
19
|
|
|
17
|
-
render json: {
|
|
20
|
+
render json: {
|
|
21
|
+
profile: profile.to_h.merge(child_jobs: build_child_jobs(profile)),
|
|
22
|
+
events_cursor: events_cursor
|
|
23
|
+
}
|
|
18
24
|
end
|
|
19
25
|
|
|
20
26
|
private
|
data/config/routes.rb
CHANGED
|
@@ -55,7 +55,7 @@ Profiler::Engine.routes.draw do
|
|
|
55
55
|
get "test_runner/runs/:id", to: "test_runner#show", as: :test_runner_run
|
|
56
56
|
get "test_runner/runs/:id/stream", to: "test_runner#stream", as: :test_runner_run_stream
|
|
57
57
|
delete "test_runner/runs/:id", to: "test_runner#destroy"
|
|
58
|
-
get "events/:token", to: "events#
|
|
58
|
+
get "events/:token", to: "events#show", as: :profile_events
|
|
59
59
|
|
|
60
60
|
# Cluster endpoints (master-side), routed only on a node with cluster_master set
|
|
61
61
|
constraints(->(_request) { Profiler.configuration.cluster_master? }) do
|
|
@@ -25,7 +25,11 @@ module Profiler
|
|
|
25
25
|
BUNDLE_GEMFILE BUNDLE_PATH BUNDLE_WITHOUT GEM_HOME GEM_PATH BOOTSNAP_CACHE_DIR
|
|
26
26
|
].freeze
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
# Options whose default depends on the Rails environment: the railtie sets them only when
|
|
29
|
+
# the application has not assigned them itself (see #default).
|
|
30
|
+
RAILS_DEFAULTED = %i[enabled storage track_tests].freeze
|
|
31
|
+
|
|
32
|
+
attr_accessor :storage_options, :collectors,
|
|
29
33
|
:skip_paths, :slow_query_threshold, :max_queries_warning,
|
|
30
34
|
:track_memory, :memory_warning_threshold,
|
|
31
35
|
:mcp_enabled, :mcp_transport, :mcp_port,
|
|
@@ -36,7 +40,7 @@ module Profiler
|
|
|
36
40
|
:track_jobs,
|
|
37
41
|
:track_console,
|
|
38
42
|
:apply_env_overrides_when_disabled,
|
|
39
|
-
:
|
|
43
|
+
:test_runner_allow_undiscovered_files,
|
|
40
44
|
:track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
|
|
41
45
|
:compress_bodies, :compress_body_threshold,
|
|
42
46
|
:redact_sensitive_data, :filter_parameters, :env_allowlist,
|
|
@@ -47,9 +51,10 @@ module Profiler
|
|
|
47
51
|
|
|
48
52
|
attr_writer :tmp_path
|
|
49
53
|
|
|
50
|
-
attr_reader :authorize_block
|
|
54
|
+
attr_reader :authorize_block, :enabled, :track_tests
|
|
51
55
|
|
|
52
56
|
def initialize
|
|
57
|
+
@assigned = []
|
|
53
58
|
@enabled = false
|
|
54
59
|
@storage = :memory
|
|
55
60
|
@storage_options = {}
|
|
@@ -112,6 +117,26 @@ module Profiler
|
|
|
112
117
|
@tmp_path || default_tmp_path
|
|
113
118
|
end
|
|
114
119
|
|
|
120
|
+
def enabled=(value)
|
|
121
|
+
@assigned |= [:enabled]
|
|
122
|
+
@enabled = value
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def track_tests=(value)
|
|
126
|
+
@assigned |= [:track_tests]
|
|
127
|
+
@track_tests = value
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Sets one of RAILS_DEFAULTED to value unless the application assigned it, in
|
|
131
|
+
# config/application.rb for instance, which runs before the railtie's initializers.
|
|
132
|
+
def default(option, value)
|
|
133
|
+
raise ArgumentError, "#{option} has no Rails default" unless RAILS_DEFAULTED.include?(option)
|
|
134
|
+
return if @assigned.include?(option)
|
|
135
|
+
|
|
136
|
+
public_send("#{option}=", value)
|
|
137
|
+
@assigned.delete(option)
|
|
138
|
+
end
|
|
139
|
+
|
|
115
140
|
def slave?
|
|
116
141
|
!master_url.nil? && !master_url.empty?
|
|
117
142
|
end
|
|
@@ -162,6 +187,7 @@ module Profiler
|
|
|
162
187
|
end
|
|
163
188
|
|
|
164
189
|
def storage=(value)
|
|
190
|
+
@assigned |= [:storage]
|
|
165
191
|
@storage = value
|
|
166
192
|
@storage_backend = nil
|
|
167
193
|
end
|
|
@@ -8,11 +8,12 @@ module Profiler
|
|
|
8
8
|
included do
|
|
9
9
|
attr_accessor :profiler_parent_token
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
# Ahead of the application's callbacks, which may already be there when this is included.
|
|
12
|
+
before_enqueue(prepend: true) do |job|
|
|
12
13
|
job.profiler_parent_token = Profiler::CurrentContext.token
|
|
13
14
|
end
|
|
14
15
|
|
|
15
|
-
around_perform do |job, block|
|
|
16
|
+
around_perform(prepend: true) do |job, block|
|
|
16
17
|
Profiler::JobProfiler.profile(
|
|
17
18
|
job_class: job.class.name,
|
|
18
19
|
job_id: job.job_id,
|
|
@@ -96,11 +96,12 @@ module Profiler
|
|
|
96
96
|
loop do
|
|
97
97
|
break if Time.now > deadline && (timed_out = true)
|
|
98
98
|
run_data = proxy.get_json("/_profiler/api/test_runner/runs/#{run_id}")
|
|
99
|
-
break if
|
|
99
|
+
break if Profiler::TestRunner::RunStore::TERMINAL_STATUSES.include?(run_data["status"])
|
|
100
100
|
sleep 2
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
-
output
|
|
103
|
+
# The slave answers with RunStore::Run#to_h, which carries the whole output as "output".
|
|
104
|
+
output = run_data["output"].to_s
|
|
104
105
|
output = "…(truncated)\n" + output[-(max_output)..] if output.length > max_output
|
|
105
106
|
|
|
106
107
|
text = "# Test Run on slave '#{params["slave"]}' #{timed_out ? "(timed out)" : ""}\n\n"
|
data/lib/profiler/railtie.rb
CHANGED
|
@@ -6,27 +6,38 @@ module Profiler
|
|
|
6
6
|
class Railtie < Rails::Railtie
|
|
7
7
|
config.profiler = ActiveSupport::OrderedOptions.new
|
|
8
8
|
|
|
9
|
+
# Runs before the application's config/initializers, so that they override what it sets.
|
|
10
|
+
# The Rails defaults go only where config/application.rb, which runs earlier still, has not
|
|
11
|
+
# assigned the option, and config.profiler, also set there, is applied on top.
|
|
9
12
|
initializer "profiler.set_configs" do |app|
|
|
10
|
-
|
|
13
|
+
development_or_test = Rails.env.development? || Rails.env.test?
|
|
11
14
|
Profiler.configure do |config|
|
|
12
|
-
config.enabled
|
|
13
|
-
config.storage
|
|
14
|
-
config.
|
|
15
|
-
|
|
15
|
+
config.default(:enabled, development_or_test)
|
|
16
|
+
config.default(:storage, development_or_test ? :file : :memory)
|
|
17
|
+
config.default(:track_tests, Rails.env.test?)
|
|
18
|
+
|
|
19
|
+
app.config.profiler.each do |key, value|
|
|
20
|
+
if config.respond_to?("#{key}=")
|
|
21
|
+
config.public_send("#{key}=", value)
|
|
22
|
+
else
|
|
23
|
+
Rails.logger&.warn("[Profiler] config.profiler.#{key} is not a profiler option, ignored")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
16
26
|
end
|
|
17
27
|
end
|
|
18
28
|
|
|
29
|
+
# Always inserted here, before the application's initializers have decided `enabled`, so
|
|
30
|
+
# that the profiler keeps its place in the stack relative to the middlewares they insert;
|
|
31
|
+
# profiler.remove_disabled_middleware takes it out again once they have run.
|
|
19
32
|
initializer "profiler.insert_middleware", before: :build_middleware_stack do |app|
|
|
20
|
-
|
|
21
|
-
require_relative "middleware/profiler_middleware"
|
|
33
|
+
require_relative "middleware/profiler_middleware"
|
|
22
34
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
35
|
+
# It sets the framing headers of every profiler response, and its CORS headers only
|
|
36
|
+
# when extension_cors_enabled is set.
|
|
37
|
+
require_relative "middleware/cors_middleware"
|
|
38
|
+
app.middleware.insert_before 0, Profiler::Middleware::CorsMiddleware
|
|
27
39
|
|
|
28
|
-
|
|
29
|
-
end
|
|
40
|
+
app.middleware.insert_before 0, Profiler::Middleware::ProfilerMiddleware
|
|
30
41
|
end
|
|
31
42
|
|
|
32
43
|
initializer "profiler.load_collectors" do
|
|
@@ -52,6 +63,17 @@ module Profiler
|
|
|
52
63
|
end
|
|
53
64
|
end
|
|
54
65
|
|
|
66
|
+
# Everything below runs after the application's config/initializers, where `enabled` is
|
|
67
|
+
# decided, and before the middleware stack is built. An initializer declared without
|
|
68
|
+
# `after:` runs after the one declared before it, so these stay declared last, in order.
|
|
69
|
+
initializer "profiler.remove_disabled_middleware", after: :load_config_initializers do |app|
|
|
70
|
+
next if Profiler.configuration.enabled
|
|
71
|
+
|
|
72
|
+
# Rails applies every delete after the other middleware operations, whatever their order.
|
|
73
|
+
app.middleware.delete Profiler::Middleware::ProfilerMiddleware
|
|
74
|
+
app.middleware.delete Profiler::Middleware::CorsMiddleware
|
|
75
|
+
end
|
|
76
|
+
|
|
55
77
|
initializer "profiler.setup_test_profiler" do
|
|
56
78
|
next unless Profiler.configuration.enabled && Profiler.configuration.track_tests
|
|
57
79
|
|
|
@@ -68,16 +90,19 @@ module Profiler
|
|
|
68
90
|
|
|
69
91
|
require_relative "job_profiler"
|
|
70
92
|
|
|
93
|
+
# This runs after the application's initializers: prepend, not add, keeps the profiler
|
|
94
|
+
# ahead of the middlewares and callbacks they install, so that a job's profile covers them.
|
|
95
|
+
# Sidekiq::Middleware::Chain#prepend is there from Sidekiq 3.3 (2014) to 8.1 at least.
|
|
71
96
|
if defined?(Sidekiq)
|
|
72
97
|
require_relative "instrumentation/sidekiq_middleware"
|
|
73
98
|
Sidekiq.configure_server do |config|
|
|
74
99
|
config.server_middleware do |chain|
|
|
75
|
-
chain.
|
|
100
|
+
chain.prepend Profiler::Instrumentation::SidekiqMiddleware
|
|
76
101
|
end
|
|
77
102
|
end
|
|
78
103
|
Sidekiq.configure_client do |config|
|
|
79
104
|
config.client_middleware do |chain|
|
|
80
|
-
chain.
|
|
105
|
+
chain.prepend Profiler::Instrumentation::SidekiqClientMiddleware
|
|
81
106
|
end
|
|
82
107
|
end
|
|
83
108
|
end
|
|
@@ -1,46 +1,47 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "singleton"
|
|
4
|
-
require "securerandom"
|
|
5
|
-
require "timeout"
|
|
6
|
-
require "set"
|
|
7
|
-
require "concurrent"
|
|
8
4
|
|
|
9
5
|
module Profiler
|
|
10
6
|
module SSE
|
|
7
|
+
# Remembers, per profile token, the version of its last save, so that a page showing the
|
|
8
|
+
# profile can ask whether it changed since the version it holds. Nothing here waits: a
|
|
9
|
+
# request asking for the version is answered at once, and the toolbar comes back later (see
|
|
10
|
+
# Api::EventsController).
|
|
11
|
+
#
|
|
12
|
+
# A version is the time of the save in microseconds, so that versions from the worker
|
|
13
|
+
# processes of one machine compare with each other: without Redis, each worker only knows
|
|
14
|
+
# the saves it made, and a page sees a save when one of its questions reaches that worker.
|
|
11
15
|
class EventBus
|
|
12
16
|
include Singleton
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
# The tokens remembered; the oldest saved is forgotten first. A forgotten token reads as
|
|
19
|
+
# version 0, which is never newer than what a page holds, and its next save is seen again.
|
|
20
|
+
MAX_TOKENS = 1000
|
|
17
21
|
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
@
|
|
21
|
-
id
|
|
22
|
+
def initialize
|
|
23
|
+
@versions = {}
|
|
24
|
+
@mutex = Mutex.new
|
|
22
25
|
end
|
|
23
26
|
|
|
24
|
-
def
|
|
25
|
-
|
|
27
|
+
def broadcast(token)
|
|
28
|
+
now = Process.clock_gettime(Process::CLOCK_REALTIME, :microsecond)
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
# Strictly increasing within the process, even for two saves in one microsecond.
|
|
31
|
+
version = [now, @versions.delete(token).to_i + 1].max
|
|
32
|
+
@versions[token] = version
|
|
33
|
+
@versions.shift while @versions.size > MAX_TOKENS
|
|
34
|
+
version
|
|
35
|
+
end
|
|
26
36
|
end
|
|
27
37
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@
|
|
31
|
-
next unless sub[:token] == token
|
|
32
|
-
# Empty collector set means "match all"; non-empty set filters by intersection.
|
|
33
|
-
next if sub[:collectors].any? && (sub[:collectors] & changed).empty?
|
|
34
|
-
sub[:queue] << { token: token, collectors: changed.to_a, timestamp: Time.now.to_f }
|
|
35
|
-
end
|
|
38
|
+
# The version of the last save of +token+ seen by this process, 0 when none was.
|
|
39
|
+
def version(token)
|
|
40
|
+
@mutex.synchronize { @versions.fetch(token, 0) }
|
|
36
41
|
end
|
|
37
42
|
|
|
38
|
-
def
|
|
39
|
-
|
|
40
|
-
return nil unless sub
|
|
41
|
-
Timeout.timeout(timeout) { sub[:queue].pop }
|
|
42
|
-
rescue Timeout::Error
|
|
43
|
-
nil
|
|
43
|
+
def reset!
|
|
44
|
+
@mutex.synchronize { @versions.clear }
|
|
44
45
|
end
|
|
45
46
|
end
|
|
46
47
|
end
|
|
@@ -1,78 +1,46 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "singleton"
|
|
4
|
-
require "securerandom"
|
|
5
|
-
require "timeout"
|
|
6
|
-
require "set"
|
|
7
|
-
require "json"
|
|
8
|
-
require "concurrent"
|
|
9
4
|
|
|
10
5
|
module Profiler
|
|
11
6
|
module SSE
|
|
7
|
+
# The version of each profile's last save, kept in Redis next to the profiles so that every
|
|
8
|
+
# process and every machine sharing them counts the same saves (see EventBus). A version is
|
|
9
|
+
# a counter Redis increments, so versions only compare with versions of the same Redis.
|
|
10
|
+
#
|
|
11
|
+
# Nothing waits on Redis beyond its client's own timeouts: there is no subscription and no
|
|
12
|
+
# listening thread, and a failing Redis reads as no newer save.
|
|
12
13
|
class RedisEventBus
|
|
13
14
|
include Singleton
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
KEY_PREFIX = "profiler:events"
|
|
17
|
+
# As long as RedisStore keeps a profile by default: past that, nobody looks at its page.
|
|
18
|
+
TTL = 24 * 60 * 60
|
|
16
19
|
|
|
17
|
-
def
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
id = SecureRandom.uuid
|
|
25
|
-
@subscriptions[id] = { token: token, collectors: Set.new(collectors.map(&:to_s)), queue: Queue.new }
|
|
26
|
-
ensure_listener_running
|
|
27
|
-
id
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def unsubscribe(id)
|
|
31
|
-
@subscriptions.delete(id)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def broadcast(token, collectors)
|
|
35
|
-
payload = { token: token, collectors: collectors.map(&:to_s), timestamp: Time.now.to_f }.to_json
|
|
36
|
-
publish_redis_client.publish("#{CHANNEL_PREFIX}:#{token}", payload)
|
|
20
|
+
def broadcast(token)
|
|
21
|
+
key = key(token)
|
|
22
|
+
version, _expire = redis.multi do |transaction|
|
|
23
|
+
transaction.incr(key)
|
|
24
|
+
transaction.expire(key, TTL)
|
|
25
|
+
end
|
|
26
|
+
version
|
|
37
27
|
end
|
|
38
28
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
nil
|
|
29
|
+
# The version of the last save of +token+, 0 when none is known or Redis cannot be read.
|
|
30
|
+
def version(token)
|
|
31
|
+
redis.get(key(token)).to_i
|
|
32
|
+
rescue StandardError
|
|
33
|
+
0
|
|
45
34
|
end
|
|
46
35
|
|
|
47
36
|
private
|
|
48
37
|
|
|
49
|
-
def
|
|
50
|
-
|
|
38
|
+
def key(token)
|
|
39
|
+
"#{KEY_PREFIX}:#{token}"
|
|
51
40
|
end
|
|
52
41
|
|
|
53
|
-
def
|
|
54
|
-
|
|
55
|
-
return if @listener_thread&.alive?
|
|
56
|
-
|
|
57
|
-
@listener_thread = Thread.new do
|
|
58
|
-
# Use a dedicated connection for blocking psubscribe
|
|
59
|
-
Profiler.storage.redis.dup.psubscribe("#{CHANNEL_PREFIX}:*") do |on|
|
|
60
|
-
on.pmessage do |_pattern, _channel, message|
|
|
61
|
-
payload = JSON.parse(message, symbolize_names: true)
|
|
62
|
-
token = payload[:token]
|
|
63
|
-
changed = Set.new(payload[:collectors].map(&:to_s))
|
|
64
|
-
|
|
65
|
-
@subscriptions.each_value do |sub|
|
|
66
|
-
next unless sub[:token] == token
|
|
67
|
-
next if sub[:collectors].any? && (sub[:collectors] & changed).empty?
|
|
68
|
-
sub[:queue] << { token: token, collectors: changed.to_a, timestamp: payload[:timestamp] }
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
rescue StandardError
|
|
73
|
-
# Thread restarts on the next subscribe call
|
|
74
|
-
end
|
|
75
|
-
end
|
|
42
|
+
def redis
|
|
43
|
+
Profiler.storage.redis
|
|
76
44
|
end
|
|
77
45
|
end
|
|
78
46
|
end
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
module Profiler
|
|
4
4
|
module Storage
|
|
5
5
|
class BaseStore
|
|
6
|
-
# Public interface: persists the profile then
|
|
6
|
+
# Public interface: persists the profile then records the save, for the pages showing it
|
|
7
|
+
# to see it changed (Profiler::SSE).
|
|
7
8
|
# Subclasses implement #do_save, not #save.
|
|
8
9
|
def save(token, profile)
|
|
9
10
|
result = do_save(token, profile)
|
|
10
|
-
broadcast_event(token
|
|
11
|
+
broadcast_event(token)
|
|
11
12
|
result
|
|
12
13
|
end
|
|
13
14
|
|
|
@@ -47,9 +48,8 @@ module Profiler
|
|
|
47
48
|
|
|
48
49
|
private
|
|
49
50
|
|
|
50
|
-
def broadcast_event(token
|
|
51
|
-
|
|
52
|
-
Profiler::SSE.current.broadcast(token, collectors)
|
|
51
|
+
def broadcast_event(token)
|
|
52
|
+
Profiler::SSE.current.broadcast(token)
|
|
53
53
|
rescue StandardError
|
|
54
54
|
# Never let a broadcast failure prevent profile persistence
|
|
55
55
|
end
|
|
@@ -21,6 +21,7 @@ module Profiler
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
TERMINAL_STATUSES = %w[passed failed killed error].freeze
|
|
24
|
+
NOT_FOUND = { chunks: [].freeze, status: "not_found", position: 0, finished: true }.freeze
|
|
24
25
|
|
|
25
26
|
def initialize
|
|
26
27
|
@runs = Concurrent::Hash.new
|
|
@@ -68,16 +69,25 @@ module Profiler
|
|
|
68
69
|
# last bytes that could start a credential wait for the next piece, so that none is ever
|
|
69
70
|
# shown cut in two. What is held back is released by finish_output, once the process has
|
|
70
71
|
# nothing more to print: a killed run still prints its summary after its status changed.
|
|
72
|
+
#
|
|
73
|
+
# The process is read in binary pieces of a fixed size, so a character can be cut in two
|
|
74
|
+
# as well: its first bytes wait for the next piece the same way. What is shown is UTF-8,
|
|
75
|
+
# with any byte that is not replaced by U+FFFD, so that every reader (JSON of the run, the
|
|
76
|
+
# stream, run_tests) gets text it can encode.
|
|
71
77
|
def append_output(id, chunk)
|
|
72
78
|
run = @runs[id]
|
|
73
79
|
return unless run
|
|
74
80
|
|
|
75
81
|
@held_lock.synchronize do
|
|
76
|
-
text = Profiler::Redaction.hide_credentials(@held.fetch(id, "".b) + chunk.to_s.b)
|
|
77
|
-
holdback = @output_finished[id]
|
|
82
|
+
text = Profiler::Redaction.hide_credentials(@held.fetch(id, "".b) + chunk.to_s.b).b
|
|
83
|
+
holdback = if @output_finished[id]
|
|
84
|
+
0
|
|
85
|
+
else
|
|
86
|
+
[Profiler::Redaction.held_back_bytes(text), incomplete_character_bytes(text)].max
|
|
87
|
+
end
|
|
78
88
|
cut = text.bytesize - holdback
|
|
79
89
|
@held[id] = text.byteslice(cut, text.bytesize - cut)
|
|
80
|
-
shown = text.byteslice(0, cut)
|
|
90
|
+
shown = as_text(text.byteslice(0, cut))
|
|
81
91
|
run.output_lines.push(shown) unless shown.empty?
|
|
82
92
|
end
|
|
83
93
|
signal(id)
|
|
@@ -92,16 +102,29 @@ module Profiler
|
|
|
92
102
|
@held_lock.synchronize do
|
|
93
103
|
@output_finished[id] = true
|
|
94
104
|
rest = @held.delete(id)
|
|
95
|
-
run.output_lines.push(rest) if rest && !rest.empty?
|
|
105
|
+
run.output_lines.push(as_text(rest)) if rest && !rest.empty?
|
|
96
106
|
end
|
|
97
107
|
signal(id)
|
|
98
108
|
end
|
|
99
109
|
|
|
110
|
+
# The output from +position+ on, as it is now, without waiting for more.
|
|
111
|
+
# Returns { chunks: [...], status: "...", position: N, finished: bool }, finished once the
|
|
112
|
+
# status is terminal and the reader of the process has read everything: a killed run gets
|
|
113
|
+
# its status at once, and prints its summary after.
|
|
114
|
+
def read_output(id, position:)
|
|
115
|
+
run = @runs[id]
|
|
116
|
+
return NOT_FOUND unless run
|
|
117
|
+
|
|
118
|
+
result = snapshot(run, position)
|
|
119
|
+
result[:finished] &&= @output_finished.fetch(id, false)
|
|
120
|
+
result
|
|
121
|
+
end
|
|
122
|
+
|
|
100
123
|
# Block until new output is available at +position+ or the run terminates.
|
|
101
|
-
# Returns
|
|
124
|
+
# Returns the same as read_output.
|
|
102
125
|
def wait_for_output(id, position:, timeout: 10)
|
|
103
126
|
run = @runs[id]
|
|
104
|
-
return
|
|
127
|
+
return NOT_FOUND unless run
|
|
105
128
|
|
|
106
129
|
lock = @locks[id]
|
|
107
130
|
return snapshot(run, position) unless lock
|
|
@@ -128,6 +151,26 @@ module Profiler
|
|
|
128
151
|
lock[:mutex].synchronize { lock[:cond].broadcast }
|
|
129
152
|
end
|
|
130
153
|
|
|
154
|
+
# How many bytes at the end of +bytes+ start a UTF-8 character that the next piece ends.
|
|
155
|
+
def incomplete_character_bytes(bytes)
|
|
156
|
+
[3, bytes.bytesize].min.times do |back|
|
|
157
|
+
byte = bytes.getbyte(bytes.bytesize - 1 - back)
|
|
158
|
+
next if byte & 0xC0 == 0x80 # a continuation byte: the start is further back
|
|
159
|
+
|
|
160
|
+
length = if byte >= 0xF0 then 4
|
|
161
|
+
elsif byte >= 0xE0 then 3
|
|
162
|
+
elsif byte >= 0xC0 then 2
|
|
163
|
+
else 1
|
|
164
|
+
end
|
|
165
|
+
return length > back + 1 ? back + 1 : 0
|
|
166
|
+
end
|
|
167
|
+
0
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def as_text(bytes)
|
|
171
|
+
bytes.dup.force_encoding(Encoding::UTF_8).scrub
|
|
172
|
+
end
|
|
173
|
+
|
|
131
174
|
def forget_output(id)
|
|
132
175
|
@held.delete(id)
|
|
133
176
|
@output_finished.delete(id)
|
|
@@ -118,13 +118,14 @@ module Profiler
|
|
|
118
118
|
Thread.new do
|
|
119
119
|
run_process(run)
|
|
120
120
|
rescue => e
|
|
121
|
+
# The output first: a page following the run stops at its terminal status.
|
|
122
|
+
Profiler::TestRunner.run_store.append_output(run.id, "\n[Profiler] Error: #{e.message}\n")
|
|
123
|
+
Profiler::TestRunner.run_store.finish_output(run.id)
|
|
121
124
|
Profiler::TestRunner.run_store.update(
|
|
122
125
|
run.id,
|
|
123
126
|
status: "error",
|
|
124
127
|
finished_at: Time.now
|
|
125
128
|
)
|
|
126
|
-
Profiler::TestRunner.run_store.append_output(run.id, "\n[Profiler] Error: #{e.message}\n")
|
|
127
|
-
Profiler::TestRunner.run_store.finish_output(run.id)
|
|
128
129
|
end
|
|
129
130
|
end
|
|
130
131
|
|
|
@@ -151,7 +152,11 @@ module Profiler
|
|
|
151
152
|
end
|
|
152
153
|
|
|
153
154
|
exit_code = $?.exitstatus || 0
|
|
154
|
-
|
|
155
|
+
# A run killed from the profiler stays killed, whatever the process answered to TERM.
|
|
156
|
+
status = if run.status == "killed" then "killed"
|
|
157
|
+
elsif exit_code == 0 then "passed"
|
|
158
|
+
else "failed"
|
|
159
|
+
end
|
|
155
160
|
|
|
156
161
|
Profiler::TestRunner.run_store.update(
|
|
157
162
|
run.id,
|
data/lib/profiler/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-profiler
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.30.
|
|
4
|
+
version: 0.30.15
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sébastien Duplessy
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|