rails-profiler 0.30.14 → 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 +39 -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/mcp/tools/run_tests.rb +3 -2
- 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 +1 -1
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,45 @@ 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
|
+
|
|
22
61
|
## [0.30.14] - 2026-10-05
|
|
23
62
|
|
|
24
63
|
<!-- 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
|
|
@@ -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"
|
|
@@ -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