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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e09eb31d9bcb170273cd7c9df1f6e149d772d38cd52fd0f6d2930644054ccc8d
4
- data.tar.gz: 5b8996d059b59b4a69e702ff238060dd83e08614fa416ae09ed8ed077d0d550f
3
+ metadata.gz: 1c74753b10258047c15aafb6de0c2b5900bc16f01f4b499405b5b72d0d6d3b9e
4
+ data.tar.gz: 55ad17c8d51cf4eb8e2ed9ec3fd7158a15e23becba666b204b11d7b7c81f0e47
5
5
  SHA512:
6
- metadata.gz: 5042a2f2c93acb5fd91e27907880d01b51aecaa7316a34886b827ec73584d9e3dcf356d07079f368066511fe447b06ecbeba1520e5a245befd82eb10f45b2d46
7
- data.tar.gz: 8ca5df627c525ad56bddfcfa01771f0e8d23f2c3b6ade75d71dde0ea9db85a2d06ca2b2807f834f6195aba3423197d674986033427edae7d612ada1b7398b028
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, collectors, onUpdate) {
4216
- const [connected, setConnected] = d2(false);
4217
- const fallbackRef = A2(null);
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 clearFallback = () => {
4221
- if (fallbackRef.current !== null) {
4222
- clearInterval(fallbackRef.current);
4223
- fallbackRef.current = null;
4224
- }
4225
- };
4226
- const params = new URLSearchParams();
4227
- collectors.forEach((c3) => params.append("collectors[]", c3));
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
- es.onopen = () => {
4234
- setConnected(true);
4235
- clearFallback();
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
- es.close();
4246
- clearFallback();
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.close();
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
- include ActionController::Live
9
+ VERSION_FORMAT = /\A\d+\z/
7
10
 
8
- def subscribe
9
- response.headers["Content-Type"] = "text/event-stream"
10
- response.headers["Cache-Control"] = "no-cache"
11
- response.headers["X-Accel-Buffering"] = "no"
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
- sse = ActionController::Live::SSE.new(response.stream, retry: 3000, event: "profile_update")
14
- id = Profiler::SSE.current.subscribe(params[:token], Array(params[:collectors]))
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
- include ActionController::Live
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
- # SSE endpoint — streams output chunks as server-sent events.
45
- # Replaces polling for live test output in the frontend.
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
- run = Profiler::TestRunner.run_store.find(params[:id])
48
- unless run
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
- response.headers["Content-Type"] = "text/event-stream"
54
- response.headers["Cache-Control"] = "no-cache"
55
- response.headers["X-Accel-Buffering"] = "no"
56
-
57
- sse = SSE.new(response.stream, retry: 1000, event: "output")
58
- position = 0
59
-
60
- begin
61
- loop do
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: { profile: profile.to_h.merge(child_jobs: build_child_jobs(profile)) }
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#subscribe", as: :profile_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
- return nil if Array(allowed).any? { |entry| covers?(normalize(entry), target) }
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 are refused
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.include?("/") }
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
- :cluster_allowed_slave_urls, :cluster_allow_insecure_http
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 %w[completed failed cancelled].include?(run_data["status"])
99
+ break if Profiler::TestRunner::RunStore::TERMINAL_STATUSES.include?(run_data["status"])
100
100
  sleep 2
101
101
  end
102
102
 
103
- output = run_data["output_lines"]&.join.to_s
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
- def initialize
15
- @subscriptions = Concurrent::Hash.new
16
- end
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 subscribe(token, collectors)
19
- id = SecureRandom.uuid
20
- @subscriptions[id] = { token: token, collectors: Set.new(collectors.map(&:to_s)), queue: Queue.new }
21
- id
22
+ def initialize
23
+ @versions = {}
24
+ @mutex = Mutex.new
22
25
  end
23
26
 
24
- def unsubscribe(id)
25
- @subscriptions.delete(id)
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
- def broadcast(token, collectors)
29
- changed = Set.new(collectors.map(&:to_s))
30
- @subscriptions.each_value do |sub|
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 wait_for_event(id, timeout: 30)
39
- sub = @subscriptions[id]
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
- CHANNEL_PREFIX = "profiler:events"
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 initialize
18
- @subscriptions = Concurrent::Hash.new
19
- @listener_thread = nil
20
- @listener_mutex = Mutex.new
21
- end
22
-
23
- def subscribe(token, collectors)
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
- def wait_for_event(id, timeout: 30)
40
- sub = @subscriptions[id]
41
- return nil unless sub
42
- Timeout.timeout(timeout) { sub[:queue].pop }
43
- rescue Timeout::Error
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 publish_redis_client
50
- Profiler.storage.redis
38
+ def key(token)
39
+ "#{KEY_PREFIX}:#{token}"
51
40
  end
52
41
 
53
- def ensure_listener_running
54
- @listener_mutex.synchronize do
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 fires an SSE broadcast.
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, profile)
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, profile)
51
- collectors = profile.collectors_data.keys
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] ? 0 : Profiler::Redaction.held_back_bytes(text)
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).force_encoding(chunk.to_s.encoding)
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 { chunks: [...], status: "...", position: N, finished: bool }.
124
+ # Returns the same as read_output.
102
125
  def wait_for_output(id, position:, timeout: 10)
103
126
  run = @runs[id]
104
- return { chunks: [], status: "not_found", position: 0, finished: true } unless run
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
- status = exit_code == 0 ? "passed" : "failed"
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,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.30.14"
4
+ VERSION = "0.31.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.30.14
4
+ version: 0.31.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy