rails-profiler 0.30.14 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -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/cluster/security.rb +46 -4
- data/lib/profiler/configuration.rb +25 -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: 1c74753b10258047c15aafb6de0c2b5900bc16f01f4b499405b5b72d0d6d3b9e
|
|
4
|
+
data.tar.gz: 55ad17c8d51cf4eb8e2ed9ec3fd7158a15e23becba666b204b11d7b7c81f0e47
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f1984f7c3ef795d9f318ddc33b64e964a8ed0171878fd913a927ad7c7d4479ba4523159598e75ddd3f52a8985bdaca9cb24bd49a8e0a0f6e1fbc025b904254ba
|
|
7
|
+
data.tar.gz: 855f08439f3cd174f3686150eed1f813b349e7591dd731b554941be172b9557ff835229268a3dce9605825e161b353a4456e444f1680ed239f40ebf1a6c37abc
|
data/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,64 @@ every commit of every tag interval is accounted for one way or the other.
|
|
|
19
19
|
|
|
20
20
|
## [Unreleased]
|
|
21
21
|
|
|
22
|
+
## [0.31.0] - 2026-10-05
|
|
23
|
+
|
|
24
|
+
<!-- stamped -->
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **Cluster:** `config.cluster_allowed_slave_urls` accepts `Regexp` entries next to URLs, for
|
|
29
|
+
slaves whose names are not known in advance, such as one container per git worktree:
|
|
30
|
+
`[%r{\Ahttp://travel-api-[a-z0-9-]+:3000\z}]`. Until now such a setup had to fall back to
|
|
31
|
+
`:any`, which reopens the server-side request forgery closed in 0.30.11. A pattern must start
|
|
32
|
+
with `\A` and end with `\z`, and compile once wrapped to match the whole URL; any other `Regexp`
|
|
33
|
+
raises an `ArgumentError` when assigned. It is matched as a whole against the URL as the master
|
|
34
|
+
keeps it (lower-case scheme and host, no default port, no trailing slash), after the existing
|
|
35
|
+
checks, which it cannot lift: user info, a query, a fragment or a `.` or `..` segment are still
|
|
36
|
+
refused, and plain HTTP to a host that is not a loopback address still needs
|
|
37
|
+
`cluster_allow_insecure_http`. A `String` entry is always a URL, never a pattern. The default,
|
|
38
|
+
`[]`, still refuses every URL. A slave URL whose path segment decodes to a `\` is now refused
|
|
39
|
+
like one that decodes to a `/`.
|
|
40
|
+
|
|
41
|
+
## [0.30.15] - 2026-10-05
|
|
42
|
+
|
|
43
|
+
<!-- stamped -->
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- **Toolbar:** Stop the toolbar from holding a server thread for as long as its page stays open.
|
|
48
|
+
Every page kept a server-sent events stream open on `/_profiler/api/events/:token`, and the
|
|
49
|
+
server waited on it in one of its threads, 30 seconds at a time; a closed page was only noticed
|
|
50
|
+
at the next 30-second heartbeat. With as many open (or recently closed) pages as the server has
|
|
51
|
+
threads (3 in the Puma configuration Rails generates), the application stopped answering. The toolbar now asks whether
|
|
52
|
+
its profile was saved again with short requests the server answers at once: after 1 s, then
|
|
53
|
+
less and less often (10 in the first minute, 2 a minute after that), none while the tab is
|
|
54
|
+
hidden, and none after 10 minutes without a save. `GET /_profiler/api/events/:token` now
|
|
55
|
+
answers JSON (`cursor`, `updated`) for a `since` version, and the toolbar data carries the
|
|
56
|
+
version it reflects as `events_cursor`. With Redis storage, saves are counted in Redis: the
|
|
57
|
+
bus no longer keeps a listening thread and a dedicated Redis connection in each process, and a
|
|
58
|
+
failing Redis no longer leaves every toolbar without updates until the next page.
|
|
59
|
+
- **Test runner:** Follow the output of a run live again on `/_profiler/test_runner`. The output
|
|
60
|
+
stream answered `500` on every request since server-sent events were added for the toolbar
|
|
61
|
+
(its controller picked up the wrong `SSE` class), so the page showed the output as it was when
|
|
62
|
+
it started following and then stopped. The stream now answers at once with the output there
|
|
63
|
+
is and the browser asks again every second, with the position it reached, while the run is in
|
|
64
|
+
progress: following a run holds no server thread either.
|
|
65
|
+
- **Test runner:** Show the output of a run as UTF-8 text. The process is read 256 bytes at a
|
|
66
|
+
time, so a character could be cut between two pieces: the output stream then failed on every
|
|
67
|
+
request, the run's JSON (`GET /_profiler/api/test_runner/runs/:id`, which `run_tests` reads on a
|
|
68
|
+
slave) warned or failed, and non-ASCII output was labelled binary. The first bytes of a cut
|
|
69
|
+
character now wait for the next piece, and bytes that are not UTF-8 show as `U+FFFD`.
|
|
70
|
+
- **Test runner:** Keep a killed run `killed`, and send its end to the page only with its last
|
|
71
|
+
output. The status of a killed run became `passed` or `failed` once the process exited, and the
|
|
72
|
+
page following a killed run, or a run whose test command failed to start, could stop before
|
|
73
|
+
the summary or the `[Profiler] Error:` line.
|
|
74
|
+
- **MCP:** `run_tests` with a `slave` (FAB-20) returns as soon as the run on the slave is over, with
|
|
75
|
+
its output. It waited for statuses the test runner never gives (`completed`, `cancelled`), so a
|
|
76
|
+
run that passed was reported as timed out after `timeout_seconds` (120 by default), holding a
|
|
77
|
+
server thread of the master all that time, and it read the output from a field the slave does
|
|
78
|
+
not send, so the output was always empty.
|
|
79
|
+
|
|
22
80
|
## [0.30.14] - 2026-10-05
|
|
23
81
|
|
|
24
82
|
<!-- 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
|
|
@@ -82,11 +82,27 @@ module Profiler
|
|
|
82
82
|
|
|
83
83
|
allowed = config.cluster_allowed_slave_urls
|
|
84
84
|
return nil if allowed == :any
|
|
85
|
-
|
|
85
|
+
|
|
86
|
+
registered = normalized_url(url)
|
|
87
|
+
return nil if Array(allowed).any? { |entry| entry_allows?(entry, target, registered) }
|
|
86
88
|
|
|
87
89
|
"#{url} is not allowed by config.cluster_allowed_slave_urls"
|
|
88
90
|
end
|
|
89
91
|
|
|
92
|
+
# Whether a Regexp entry of cluster_allowed_slave_urls is anchored at both ends: its source
|
|
93
|
+
# starts with \A and ends with an unescaped \z. \Z, ^ and $ do not count, since they let a
|
|
94
|
+
# trailing newline or another line through. A check of the written form, so that a pattern
|
|
95
|
+
# reads as what it does; the match itself is made whole by whole_url_pattern.
|
|
96
|
+
def anchored_pattern?(pattern)
|
|
97
|
+
return false unless pattern.is_a?(Regexp)
|
|
98
|
+
|
|
99
|
+
source = pattern.source
|
|
100
|
+
return false unless source.start_with?("\\A")
|
|
101
|
+
|
|
102
|
+
ending = source.match(/(\\+)z\z/)
|
|
103
|
+
!ending.nil? && ending[1].length.odd? && source.length > 4
|
|
104
|
+
end
|
|
105
|
+
|
|
90
106
|
# Same transport rule for the master URL a slave sends its secret to.
|
|
91
107
|
def master_url_denial(url)
|
|
92
108
|
target = normalize(url)
|
|
@@ -139,6 +155,32 @@ module Profiler
|
|
|
139
155
|
"#{path.inspect} is not a valid path"
|
|
140
156
|
end
|
|
141
157
|
|
|
158
|
+
# One entry of cluster_allowed_slave_urls against a URL already through normalize and the
|
|
159
|
+
# transport rule. A Regexp is matched, as a whole, against the URL as the registry keeps it
|
|
160
|
+
# (normalized_url), and only when anchored; anything else is an URL compared by covers?,
|
|
161
|
+
# so a String is never read as a pattern.
|
|
162
|
+
def entry_allows?(entry, target, registered)
|
|
163
|
+
if entry.is_a?(Regexp)
|
|
164
|
+
return false unless registered && anchored_pattern?(entry)
|
|
165
|
+
|
|
166
|
+
whole_url_pattern(entry).match?(registered)
|
|
167
|
+
else
|
|
168
|
+
covers?(normalize(entry), target)
|
|
169
|
+
end
|
|
170
|
+
rescue RegexpError
|
|
171
|
+
false
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# The pattern wrapped again in \A(?:...)\z, so that a top-level alternation (\Aa|b\z)
|
|
175
|
+
# cannot leave one branch anchored at a single end: this wrapping, not anchored_pattern?,
|
|
176
|
+
# is what makes the match cover the whole URL. Compiled once per pattern. Raises
|
|
177
|
+
# RegexpError when the wrapping does not compile, for instance when an x-mode comment
|
|
178
|
+
# swallows the closing \z.
|
|
179
|
+
def whole_url_pattern(pattern)
|
|
180
|
+
@whole_url_patterns ||= {}
|
|
181
|
+
@whole_url_patterns[pattern] ||= Regexp.new("\\A(?:#{pattern.source})\\z", pattern.options)
|
|
182
|
+
end
|
|
183
|
+
|
|
142
184
|
# Scheme, host and port compared after normalization (case, default port, IPv6 brackets),
|
|
143
185
|
# and a path prefix compared segment by segment.
|
|
144
186
|
def covers?(entry, target)
|
|
@@ -149,8 +191,8 @@ module Profiler
|
|
|
149
191
|
end
|
|
150
192
|
|
|
151
193
|
# Returns { scheme:, host:, port:, segments: }, or nil for anything that is not a plain
|
|
152
|
-
# http(s) URL: user info, a query, a fragment, or a "." or ".." path segment
|
|
153
|
-
# rather than interpreted.
|
|
194
|
+
# http(s) URL: user info, a query, a fragment, or a "." or ".." path segment, or one that
|
|
195
|
+
# decodes to a "/" or a "\\", are refused rather than interpreted.
|
|
154
196
|
def normalize(url)
|
|
155
197
|
uri = URI.parse(url.to_s.strip)
|
|
156
198
|
return nil unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
|
|
@@ -158,7 +200,7 @@ module Profiler
|
|
|
158
200
|
|
|
159
201
|
segments = uri.path.to_s.split("/").reject(&:empty?)
|
|
160
202
|
decoded = segments.map { |segment| URI.decode_www_form_component(segment) }
|
|
161
|
-
return nil if decoded.any? { |segment| segment == "." || segment == ".." || segment.
|
|
203
|
+
return nil if decoded.any? { |segment| segment == "." || segment == ".." || segment.match?(%r{[/\\]}) }
|
|
162
204
|
|
|
163
205
|
{ scheme: uri.scheme.downcase, host: uri.hostname.downcase, port: uri.port, segments: decoded }
|
|
164
206
|
rescue URI::Error, ArgumentError
|
|
@@ -47,7 +47,7 @@ module Profiler
|
|
|
47
47
|
:name, :master_url, :self_url,
|
|
48
48
|
:cluster_heartbeat_interval, :cluster_offline_threshold,
|
|
49
49
|
:cluster_master, :cluster_secret, :cluster_require_secret,
|
|
50
|
-
:
|
|
50
|
+
:cluster_allow_insecure_http
|
|
51
51
|
|
|
52
52
|
attr_writer :tmp_path
|
|
53
53
|
|
|
@@ -117,6 +117,30 @@ module Profiler
|
|
|
117
117
|
@tmp_path || default_tmp_path
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
+
attr_reader :cluster_allowed_slave_urls
|
|
121
|
+
|
|
122
|
+
# :any, or a list of slave URLs and anchored Regexp patterns (see Profiler::Cluster::Security).
|
|
123
|
+
# A Regexp that is not anchored with \A and \z is refused here, at boot, rather than ignored.
|
|
124
|
+
def cluster_allowed_slave_urls=(value)
|
|
125
|
+
unless value == :any
|
|
126
|
+
require_relative "cluster/security"
|
|
127
|
+
Array(value).grep(Regexp).each do |pattern|
|
|
128
|
+
unless Profiler::Cluster::Security.anchored_pattern?(pattern)
|
|
129
|
+
raise ArgumentError, "config.cluster_allowed_slave_urls: #{pattern.inspect} must start with \\A and " \
|
|
130
|
+
"end with \\z, so that it matches the whole slave URL"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
begin
|
|
134
|
+
Profiler::Cluster::Security.whole_url_pattern(pattern)
|
|
135
|
+
rescue RegexpError => e
|
|
136
|
+
raise ArgumentError, "config.cluster_allowed_slave_urls: #{pattern.inspect} cannot be matched against " \
|
|
137
|
+
"the whole slave URL (#{e.message}); an x-mode comment must not hide the \\z"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
@cluster_allowed_slave_urls = value
|
|
142
|
+
end
|
|
143
|
+
|
|
120
144
|
def enabled=(value)
|
|
121
145
|
@assigned |= [:enabled]
|
|
122
146
|
@enabled = value
|
|
@@ -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