rails-profiler 0.34.5 → 0.35.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 +39 -0
- data/app/assets/builds/profiler-toolbar.js +1 -1
- data/app/assets/builds/profiler.js +27 -26
- data/app/assets/images/icon.png +0 -0
- data/app/controllers/profiler/api/cluster_controller.rb +1 -1
- data/app/controllers/profiler/api/test_runner_controller.rb +10 -3
- data/app/controllers/profiler/assets_controller.rb +5 -0
- data/app/controllers/profiler/profiles_controller.rb +2 -2
- data/app/views/layouts/profiler/application.html.erb +1 -0
- data/app/views/layouts/profiler/embedded.html.erb +2 -1
- data/config/routes.rb +1 -0
- data/lib/profiler/collectors/function_profiler_collector.rb +1 -1
- data/lib/profiler/collectors/mailer_collector.rb +1 -1
- data/lib/profiler/collectors/routes_collector.rb +1 -1
- data/lib/profiler/configuration.rb +10 -0
- data/lib/profiler/mcp/server.rb +2 -2
- data/lib/profiler/mcp/tools/explain_query.rb +1 -1
- data/lib/profiler/mcp/tools/get_profile_detail.rb +7 -7
- data/lib/profiler/mcp/tools/run_tests.rb +6 -3
- data/lib/profiler/middleware/toolbar_injector.rb +2 -2
- data/lib/profiler/railtie.rb +1 -1
- data/lib/profiler/test_runner/run_store.rb +119 -24
- data/lib/profiler/test_runner/runner.rb +54 -9
- data/lib/profiler/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1bdfb0831a95ebd47c1f2d396cf1180ff1d2143385082127f16c9b32e6cb0422
|
|
4
|
+
data.tar.gz: f286f70eb7ba2fcb9c69a4fc5bb54d9a90df9d957baa04e102d7bc67d0eb9476
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7f59ab05dc3296b8068ce6a9c117c3ff48cf2a56ee057b01dd74404ab0de00391864eb2eedafb65efcbe4aea7b332d9c8c5edeb326a706841d36ef5f387c25ee
|
|
7
|
+
data.tar.gz: a730e9c3c3cc44835f535075f74cb7584d306009a7087b8c3d69360f4896110a494d42de787d5d8bf87893e5446308c67f16e96b601b2d0b344fa0525e5c9613
|
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.35.0] - 2026-10-17
|
|
23
|
+
|
|
24
|
+
<!-- stamped -->
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- The dashboard pages now carry the Chrome extension's icon as their favicon, served at `/_profiler/assets/icon.png`.
|
|
29
|
+
|
|
30
|
+
## [0.34.6] - 2026-10-16
|
|
31
|
+
|
|
32
|
+
<!-- stamped -->
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **Test runner:** `config.test_runner_kill_grace_seconds` (5 by default),
|
|
37
|
+
`config.test_runner_max_output_bytes` (5 MB by default) and `config.test_runner_max_active_runs`
|
|
38
|
+
(2 by default), described under the fixes below. `nil` lifts either limit.
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- **Test runner:** A test process ended by a signal (killed for lack of memory, crashed, stopped by
|
|
43
|
+
another program) is no longer reported `passed` with exit code 0. The run ends `signaled`, with no
|
|
44
|
+
exit code, the name of the signal in a new `signal` field of the API, a `Signal` line in the MCP
|
|
45
|
+
tool `run_tests`, a `[Profiler]` line at the end of its output, and "Ended by a signal" in the page.
|
|
46
|
+
- **Test runner:** **Stop** now stops the whole test run. The test process runs in a process group
|
|
47
|
+
of its own: `TERM` goes to the whole group, the processes rspec or rails started included, and
|
|
48
|
+
`KILL` follows after `config.test_runner_kill_grace_seconds` for whatever ignored it. A stopped
|
|
49
|
+
run used to leave those processes, and a process ignoring `TERM`, running.
|
|
50
|
+
- **Test runner:** The output a run keeps is limited to `config.test_runner_max_output_bytes`: its
|
|
51
|
+
beginning, a `[Profiler] Output truncated` line, and its last quarter once the run ends, with the
|
|
52
|
+
number of bytes left out. It used to be kept whole, however large. At most 20 finished runs are
|
|
53
|
+
kept, on top of the one hour they were already kept.
|
|
54
|
+
- **Test runner:** Starting a run while `config.test_runner_max_active_runs` runs still have their
|
|
55
|
+
test process is refused, with a `429` from the API, the reason in the page, and a tool error from
|
|
56
|
+
`run_tests`. Any number of runs could be started at once.
|
|
57
|
+
- **Test runner:** A run is only known to the server process that started it. With Puma in cluster
|
|
58
|
+
mode, a request reaching another worker gets a `404` that now gives this reason; the
|
|
59
|
+
[test runner documentation](docs/testing.md#several-server-processes) describes the limit.
|
|
60
|
+
|
|
22
61
|
## [0.34.5] - 2026-10-16
|
|
23
62
|
|
|
24
63
|
<!-- stamped -->
|
|
@@ -4098,7 +4098,7 @@
|
|
|
4098
4098
|
panel: /* @__PURE__ */ u3(RoutesPanel, { routesData }),
|
|
4099
4099
|
children: [
|
|
4100
4100
|
/* @__PURE__ */ u3("span", { class: "profiler-text--muted profiler-text--xs", children: "ROUTE" }),
|
|
4101
|
-
/* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--mono", children: routesData.matched?.pattern ?? "
|
|
4101
|
+
/* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--mono", children: routesData.matched?.pattern ?? "-" })
|
|
4102
4102
|
]
|
|
4103
4103
|
}
|
|
4104
4104
|
),
|
|
@@ -4033,7 +4033,7 @@
|
|
|
4033
4033
|
return "warning";
|
|
4034
4034
|
}
|
|
4035
4035
|
function formatBytes(bytes) {
|
|
4036
|
-
if (bytes < 0) return "
|
|
4036
|
+
if (bytes < 0) return "-";
|
|
4037
4037
|
if (bytes < 1024) return `${bytes} B`;
|
|
4038
4038
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
4039
4039
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
@@ -4682,7 +4682,7 @@
|
|
|
4682
4682
|
setVariables(data.variables);
|
|
4683
4683
|
setTotal(data.total);
|
|
4684
4684
|
setOverrides(data.overrides ?? {});
|
|
4685
|
-
showFlash("success", `Refreshed
|
|
4685
|
+
showFlash("success", `Refreshed - ${data.total} variables`);
|
|
4686
4686
|
return data;
|
|
4687
4687
|
} catch (e3) {
|
|
4688
4688
|
showFlash("error", e3.message ?? "Failed to refresh");
|
|
@@ -4956,10 +4956,10 @@
|
|
|
4956
4956
|
] })
|
|
4957
4957
|
] }),
|
|
4958
4958
|
forceReadOnly ? /* @__PURE__ */ u3("div", { style: "background:var(--profiler-bg-subtle,rgba(0,0,0,0.03));border:1px solid var(--profiler-border);border-radius:6px;padding:8px 12px;margin-bottom:16px;font-size:12px;color:var(--profiler-text-muted);", children: [
|
|
4959
|
-
"Snapshot taken at request time
|
|
4959
|
+
"Snapshot taken at request time - modify env vars in ",
|
|
4960
4960
|
/* @__PURE__ */ u3("a", { href: "/_profiler?section=env", style: "color:var(--profiler-accent);", children: "/_profiler?section=env" }),
|
|
4961
4961
|
"."
|
|
4962
|
-
] }) : /* @__PURE__ */ u3("div", { style: "background:var(--profiler-warning-bg,rgba(245,158,11,0.1));border:1px solid var(--profiler-warning,#f59e0b);border-radius:6px;padding:8px 12px;margin-bottom:16px;font-size:12px;color:var(--profiler-warning,#f59e0b);", children: "\u26A0 Changes affect the current process only
|
|
4962
|
+
] }) : /* @__PURE__ */ u3("div", { style: "background:var(--profiler-warning-bg,rgba(245,158,11,0.1));border:1px solid var(--profiler-warning,#f59e0b);border-radius:6px;padding:8px 12px;margin-bottom:16px;font-size:12px;color:var(--profiler-warning,#f59e0b);", children: "\u26A0 Changes affect the current process only - for development use." }),
|
|
4963
4963
|
flash && /* @__PURE__ */ u3("div", { style: `background:${flash.type === "success" ? "var(--profiler-success-bg,rgba(34,197,94,0.1))" : "var(--profiler-error-bg,rgba(239,68,68,0.08))"};border:1px solid ${flash.type === "success" ? "var(--profiler-success,#22c55e)" : "var(--profiler-error,#ef4444)"};border-radius:6px;padding:6px 12px;margin-bottom:12px;font-size:12px;color:${flash.type === "success" ? "var(--profiler-success,#22c55e)" : "var(--profiler-error,#ef4444)"};`, children: [
|
|
4964
4964
|
flash.type === "success" ? "\u2713" : "\u2717",
|
|
4965
4965
|
" ",
|
|
@@ -5311,7 +5311,7 @@
|
|
|
5311
5311
|
if (status === "passed") return "var(--profiler-success, #10b981)";
|
|
5312
5312
|
if (status === "failed") return "var(--profiler-error, #ef4444)";
|
|
5313
5313
|
if (status === "running") return "var(--profiler-accent, #06b6d4)";
|
|
5314
|
-
if (status === "killed" || status === "error") return "var(--profiler-warning, #f59e0b)";
|
|
5314
|
+
if (status === "killed" || status === "signaled" || status === "error") return "var(--profiler-warning, #f59e0b)";
|
|
5315
5315
|
return "var(--profiler-text-muted, #888)";
|
|
5316
5316
|
}
|
|
5317
5317
|
function statusLabel(status) {
|
|
@@ -5321,6 +5321,7 @@
|
|
|
5321
5321
|
passed: "\u2713 Passed",
|
|
5322
5322
|
failed: "\u2717 Failed",
|
|
5323
5323
|
killed: "\u25A0 Killed",
|
|
5324
|
+
signaled: "\u26A1 Ended by a signal",
|
|
5324
5325
|
error: "\u26A0 Error"
|
|
5325
5326
|
};
|
|
5326
5327
|
return map[status] || status;
|
|
@@ -5430,7 +5431,7 @@
|
|
|
5430
5431
|
}, [refetchFiles]);
|
|
5431
5432
|
y2(() => {
|
|
5432
5433
|
if (!currentRun || !isRunning) return;
|
|
5433
|
-
if (["passed", "failed", "killed", "error"].includes(currentRun.status)) {
|
|
5434
|
+
if (["passed", "failed", "killed", "signaled", "error"].includes(currentRun.status)) {
|
|
5434
5435
|
setIsRunning(false);
|
|
5435
5436
|
return;
|
|
5436
5437
|
}
|
|
@@ -5495,8 +5496,8 @@
|
|
|
5495
5496
|
const data = await startTestRun({ data: { files: Array.from(selected), framework } });
|
|
5496
5497
|
setCurrentRun(data);
|
|
5497
5498
|
setIsRunning(true);
|
|
5498
|
-
} catch {
|
|
5499
|
-
setError("Failed to start test run");
|
|
5499
|
+
} catch (e3) {
|
|
5500
|
+
setError(e3 instanceof Error && e3.message ? e3.message : "Failed to start test run");
|
|
5500
5501
|
}
|
|
5501
5502
|
};
|
|
5502
5503
|
const stopRun = async () => {
|
|
@@ -6099,7 +6100,7 @@
|
|
|
6099
6100
|
p3.duration.toFixed(2),
|
|
6100
6101
|
" ms"
|
|
6101
6102
|
] }) }),
|
|
6102
|
-
/* @__PURE__ */ u3("td", { children: p3.collectors_data?.database?.total_queries ?? "
|
|
6103
|
+
/* @__PURE__ */ u3("td", { children: p3.collectors_data?.database?.total_queries ?? "-" }),
|
|
6103
6104
|
/* @__PURE__ */ u3("td", { children: formatAllocations(allocatedObjects(p3)) }),
|
|
6104
6105
|
/* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3("span", { class: statusClass(p3.status), children: p3.status }) }),
|
|
6105
6106
|
/* @__PURE__ */ u3("td", { class: "profiler-text--xs profiler-text--mono profiler-text--muted", children: /* @__PURE__ */ u3("button", { class: "token-copy", onClick: () => copyToken(p3.token), title: "Copy full token", children: copiedToken === p3.token ? "\u2713" : p3.token.substring(0, 8) + "\u2026" }) }),
|
|
@@ -6262,7 +6263,7 @@
|
|
|
6262
6263
|
p3.duration.toFixed(2),
|
|
6263
6264
|
" ms"
|
|
6264
6265
|
] }) }),
|
|
6265
|
-
/* @__PURE__ */ u3("td", { children: p3.collectors_data?.database?.total_queries ?? "
|
|
6266
|
+
/* @__PURE__ */ u3("td", { children: p3.collectors_data?.database?.total_queries ?? "-" }),
|
|
6266
6267
|
/* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3("span", { class: isError ? "badge-error" : "badge-success", children: isError ? "\u2717 Error" : "\u2713 OK" }) }),
|
|
6267
6268
|
/* @__PURE__ */ u3("td", { class: "profiler-text--xs profiler-text--mono profiler-text--muted", children: /* @__PURE__ */ u3("button", { class: "token-copy", onClick: () => copyToken(p3.token), title: "Copy full token", children: copiedToken === p3.token ? "\u2713" : p3.token.substring(0, 8) + "\u2026" }) }),
|
|
6268
6269
|
/* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3("button", { class: "btn-row-delete", onClick: () => deleteConsole2(p3.token), title: "Delete", children: "\xD7" }) })
|
|
@@ -6345,7 +6346,7 @@
|
|
|
6345
6346
|
p3.duration.toFixed(2),
|
|
6346
6347
|
" ms"
|
|
6347
6348
|
] }) }),
|
|
6348
|
-
/* @__PURE__ */ u3("td", { children: p3.collectors_data?.database?.total_queries ?? "
|
|
6349
|
+
/* @__PURE__ */ u3("td", { children: p3.collectors_data?.database?.total_queries ?? "-" }),
|
|
6349
6350
|
/* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3("span", { class: status === "failed" ? "badge-error" : status === "pending" ? "badge-warning" : "badge-success", children: status === "failed" ? "\u2717 Failed" : status === "pending" ? "\u23F8 Pending" : "\u2713 Passed" }) }),
|
|
6350
6351
|
/* @__PURE__ */ u3("td", { class: "profiler-text--xs profiler-text--mono profiler-text--muted", children: /* @__PURE__ */ u3("button", { class: "token-copy", onClick: () => copyToken(p3.token), title: "Copy full token", children: copiedToken === p3.token ? "\u2713" : p3.token.substring(0, 8) + "\u2026" }) }),
|
|
6351
6352
|
/* @__PURE__ */ u3("td", { children: /* @__PURE__ */ u3("button", { class: "btn-row-delete", onClick: () => deleteTest2(p3.token), title: "Delete", children: "\xD7" }) })
|
|
@@ -6740,7 +6741,7 @@
|
|
|
6740
6741
|
/* @__PURE__ */ u3("span", { class: "profiler-alert-banner__icon", children: "\u26A0\uFE0F" }),
|
|
6741
6742
|
/* @__PURE__ */ u3("div", { style: "flex:1", children: [
|
|
6742
6743
|
/* @__PURE__ */ u3("strong", { children: "Potential N+1 detected" }),
|
|
6743
|
-
"
|
|
6744
|
+
" - ",
|
|
6744
6745
|
n1Groups.length,
|
|
6745
6746
|
" pattern",
|
|
6746
6747
|
n1Groups.length > 1 ? "s" : "",
|
|
@@ -7813,7 +7814,7 @@
|
|
|
7813
7814
|
class: `profiler-fn-profiling__mode-btn${mode === m3 ? " profiler-fn-profiling__mode-btn--active" : ""}`,
|
|
7814
7815
|
onClick: () => mode !== m3 && onModeChange(m3),
|
|
7815
7816
|
disabled: modeUpdating,
|
|
7816
|
-
title: m3 === "full" ? "Exhaustive TracePoint
|
|
7817
|
+
title: m3 === "full" ? "Exhaustive TracePoint - timing + allocations + memory bytes" : "StackProf sampling - very low overhead",
|
|
7817
7818
|
children: m3.charAt(0).toUpperCase() + m3.slice(1)
|
|
7818
7819
|
},
|
|
7819
7820
|
m3
|
|
@@ -7863,7 +7864,7 @@
|
|
|
7863
7864
|
": statistical sampling with the stackprof gem, to add to the application's Gemfile; very low overhead (<1%), on by default once stackprof is installed.",
|
|
7864
7865
|
" ",
|
|
7865
7866
|
/* @__PURE__ */ u3("strong", { children: "Full" }),
|
|
7866
|
-
": exhaustive TracePoint tracing with memory bytes
|
|
7867
|
+
": exhaustive TracePoint tracing with memory bytes - significant overhead."
|
|
7867
7868
|
] }),
|
|
7868
7869
|
enabled && stackprofMissing && /* @__PURE__ */ u3("p", { class: "profiler-fn-profiling__hint", children: [
|
|
7869
7870
|
"No data: sampling needs the ",
|
|
@@ -7883,7 +7884,7 @@
|
|
|
7883
7884
|
data.frame_cap_reached && /* @__PURE__ */ u3("div", { class: "profiler-fn-profiling__cap-warning", children: [
|
|
7884
7885
|
"\u26A0 Frame cap reached (",
|
|
7885
7886
|
data.max_frames,
|
|
7886
|
-
' frames)
|
|
7887
|
+
' frames) - call tree is truncated. Increase "Max frames" to capture more.'
|
|
7887
7888
|
] }),
|
|
7888
7889
|
/* @__PURE__ */ u3("div", { class: "profiler-flamegraph__stats", style: { marginTop: "0.75rem" }, children: [
|
|
7889
7890
|
/* @__PURE__ */ u3("div", { class: "stat-item", children: [
|
|
@@ -8580,8 +8581,8 @@
|
|
|
8580
8581
|
filtered.map((route, i3) => /* @__PURE__ */ u3("div", { class: `profiler-flex-table__row${route.matched ? " profiler-flex-table__row--matched" : ""}`, children: [
|
|
8581
8582
|
/* @__PURE__ */ u3("span", { class: "profiler-flex-table__cell--fixed", children: /* @__PURE__ */ u3(VerbBadge, { verb: route.verb }) }),
|
|
8582
8583
|
/* @__PURE__ */ u3("span", { class: "profiler-flex-table__cell profiler-text--mono profiler-text--xs", title: route.pattern, children: route.pattern }),
|
|
8583
|
-
/* @__PURE__ */ u3("span", { class: "profiler-flex-table__cell profiler-flex-table__cell--muted profiler-text--mono profiler-text--xs", title: route.name ? `${route.name}_path` : "", children: route.name ? `${route.name}_path` : "
|
|
8584
|
-
/* @__PURE__ */ u3("span", { class: "profiler-flex-table__cell profiler-flex-table__cell--muted profiler-text--mono profiler-text--xs", title: route.controller_action ?? "", children: route.controller_action ?? "
|
|
8584
|
+
/* @__PURE__ */ u3("span", { class: "profiler-flex-table__cell profiler-flex-table__cell--muted profiler-text--mono profiler-text--xs", title: route.name ? `${route.name}_path` : "", children: route.name ? `${route.name}_path` : "-" }),
|
|
8585
|
+
/* @__PURE__ */ u3("span", { class: "profiler-flex-table__cell profiler-flex-table__cell--muted profiler-text--mono profiler-text--xs", title: route.controller_action ?? "", children: route.controller_action ?? "-" })
|
|
8585
8586
|
] }, i3))
|
|
8586
8587
|
] })
|
|
8587
8588
|
] });
|
|
@@ -8691,7 +8692,7 @@
|
|
|
8691
8692
|
const hasText = !!email.body_text;
|
|
8692
8693
|
if (!email.body_captured) {
|
|
8693
8694
|
return /* @__PURE__ */ u3("div", { class: "profiler-mt-3 profiler-text--xs profiler-text--muted", children: [
|
|
8694
|
-
"Body not captured
|
|
8695
|
+
"Body not captured - enable ",
|
|
8695
8696
|
/* @__PURE__ */ u3("code", { children: "config.capture_mail_body = true" }),
|
|
8696
8697
|
" in your profiler initializer"
|
|
8697
8698
|
] });
|
|
@@ -8777,7 +8778,7 @@
|
|
|
8777
8778
|
] }),
|
|
8778
8779
|
/* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
|
|
8779
8780
|
/* @__PURE__ */ u3("span", { children: "Render" }),
|
|
8780
|
-
/* @__PURE__ */ u3("span", { class: "profiler-query-card__duration", children: email.duration_ms != null ? `${email.duration_ms} ms` : "
|
|
8781
|
+
/* @__PURE__ */ u3("span", { class: "profiler-query-card__duration", children: email.duration_ms != null ? `${email.duration_ms} ms` : "-" })
|
|
8781
8782
|
] }),
|
|
8782
8783
|
email.delivery_ms != null && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
|
|
8783
8784
|
/* @__PURE__ */ u3("span", { children: "Delivery" }),
|
|
@@ -8809,7 +8810,7 @@
|
|
|
8809
8810
|
email.action
|
|
8810
8811
|
] }),
|
|
8811
8812
|
email.subject && /* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
|
|
8812
|
-
"
|
|
8813
|
+
" - ",
|
|
8813
8814
|
email.subject.slice(0, 60),
|
|
8814
8815
|
email.subject.length > 60 ? "\u2026" : ""
|
|
8815
8816
|
] })
|
|
@@ -8890,7 +8891,7 @@
|
|
|
8890
8891
|
/* @__PURE__ */ u3("span", { class: "profiler-alert-banner__icon", children: "\u26A0\uFE0F" }),
|
|
8891
8892
|
/* @__PURE__ */ u3("div", { style: { flex: 1 }, children: [
|
|
8892
8893
|
/* @__PURE__ */ u3("strong", { children: "Send loop detected" }),
|
|
8893
|
-
"
|
|
8894
|
+
" - ",
|
|
8894
8895
|
mailerData.loop_warnings.length,
|
|
8895
8896
|
" pattern",
|
|
8896
8897
|
mailerData.loop_warnings.length > 1 ? "s" : "",
|
|
@@ -8899,7 +8900,7 @@
|
|
|
8899
8900
|
] }),
|
|
8900
8901
|
mailerData.truncated && /* @__PURE__ */ u3("div", { class: "profiler-alert-banner profiler-alert-banner--warning profiler-mb-4", children: [
|
|
8901
8902
|
/* @__PURE__ */ u3("span", { class: "profiler-alert-banner__icon", children: "\u26A0\uFE0F" }),
|
|
8902
|
-
/* @__PURE__ */ u3("span", { children: "Showing first 50 emails only
|
|
8903
|
+
/* @__PURE__ */ u3("span", { children: "Showing first 50 emails only - additional emails were truncated" })
|
|
8903
8904
|
] }),
|
|
8904
8905
|
mailerData.emails.length > 0 && /* @__PURE__ */ u3(k, { children: [
|
|
8905
8906
|
/* @__PURE__ */ u3("h3", { class: "profiler-text--lg profiler-mt-4 profiler-mb-3", children: [
|
|
@@ -8911,7 +8912,7 @@
|
|
|
8911
8912
|
mailerData.queued && mailerData.queued.length > 0 && /* @__PURE__ */ u3(k, { children: [
|
|
8912
8913
|
/* @__PURE__ */ u3("h3", { class: "profiler-text--lg profiler-mt-4 profiler-mb-2", children: [
|
|
8913
8914
|
"Queued",
|
|
8914
|
-
/* @__PURE__ */ u3("span", { class: "profiler-text--muted profiler-text--sm", style: { fontWeight: "normal", marginLeft: "8px" }, children: "deliver_later
|
|
8915
|
+
/* @__PURE__ */ u3("span", { class: "profiler-text--muted profiler-text--sm", style: { fontWeight: "normal", marginLeft: "8px" }, children: "deliver_later - will be sent in a background job" })
|
|
8915
8916
|
] }),
|
|
8916
8917
|
mailerData.queued.map((email, i3) => /* @__PURE__ */ u3(QueuedRow, { email }, `q-${i3}`))
|
|
8917
8918
|
] }),
|
|
@@ -8978,7 +8979,7 @@
|
|
|
8978
8979
|
profile.gem_version && profile.gem_version !== getGemVersion() && /* @__PURE__ */ u3("div", { class: "profiler-version-mismatch", children: [
|
|
8979
8980
|
"\u26A0\uFE0F Profil captur\xE9 avec la version ",
|
|
8980
8981
|
/* @__PURE__ */ u3("strong", { children: profile.gem_version }),
|
|
8981
|
-
"
|
|
8982
|
+
" - version actuelle : ",
|
|
8982
8983
|
/* @__PURE__ */ u3("strong", { children: getGemVersion() })
|
|
8983
8984
|
] })
|
|
8984
8985
|
] }),
|
|
@@ -9132,7 +9133,7 @@
|
|
|
9132
9133
|
profile.gem_version && profile.gem_version !== getGemVersion() && /* @__PURE__ */ u3("div", { class: "profiler-version-mismatch", children: [
|
|
9133
9134
|
"\u26A0\uFE0F Profil captur\xE9 avec la version ",
|
|
9134
9135
|
/* @__PURE__ */ u3("strong", { children: profile.gem_version }),
|
|
9135
|
-
"
|
|
9136
|
+
" - version actuelle : ",
|
|
9136
9137
|
/* @__PURE__ */ u3("strong", { children: getGemVersion() })
|
|
9137
9138
|
] }),
|
|
9138
9139
|
parent && /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2 profiler-mt-2 profiler-text--sm", children: [
|
|
@@ -9261,7 +9262,7 @@
|
|
|
9261
9262
|
profile.gem_version && profile.gem_version !== getGemVersion() && /* @__PURE__ */ u3("div", { class: "profiler-version-mismatch", children: [
|
|
9262
9263
|
"\u26A0\uFE0F Profil captur\xE9 avec la version ",
|
|
9263
9264
|
/* @__PURE__ */ u3("strong", { children: profile.gem_version }),
|
|
9264
|
-
"
|
|
9265
|
+
" - version actuelle : ",
|
|
9265
9266
|
/* @__PURE__ */ u3("strong", { children: getGemVersion() })
|
|
9266
9267
|
] })
|
|
9267
9268
|
] }),
|
|
Binary file
|
|
@@ -27,7 +27,7 @@ module Profiler
|
|
|
27
27
|
name = params[:name].to_s
|
|
28
28
|
entry = Profiler.slave_registry.heartbeat(name)
|
|
29
29
|
if entry.nil?
|
|
30
|
-
render json: { error: "Unknown slave
|
|
30
|
+
render json: { error: "Unknown slave - please re-register" }, status: :not_found
|
|
31
31
|
else
|
|
32
32
|
render json: { ok: true }
|
|
33
33
|
end
|
|
@@ -10,6 +10,10 @@ module Profiler
|
|
|
10
10
|
# How long the page waits before asking for the output that came since.
|
|
11
11
|
STREAM_RETRY_MS = 1000
|
|
12
12
|
|
|
13
|
+
# Runs live in the memory of the server process that started them.
|
|
14
|
+
RUN_NOT_FOUND = "Run not found. Runs are kept by the server process that started them: " \
|
|
15
|
+
"with several processes (Puma in cluster mode), follow a run from that process."
|
|
16
|
+
|
|
13
17
|
def files
|
|
14
18
|
framework = params[:framework]
|
|
15
19
|
tree = Profiler::TestRunner::Discovery.files(framework: framework)
|
|
@@ -34,11 +38,13 @@ module Profiler
|
|
|
34
38
|
render json: run.to_h, status: :created
|
|
35
39
|
rescue Profiler::TestRunner::InvalidFileError => e
|
|
36
40
|
render json: { error: e.message }, status: :unprocessable_entity
|
|
41
|
+
rescue Profiler::TestRunner::TooManyRunsError => e
|
|
42
|
+
render json: { error: e.message }, status: :too_many_requests
|
|
37
43
|
end
|
|
38
44
|
|
|
39
45
|
def show
|
|
40
46
|
run = Profiler::TestRunner.run_store.find(params[:id])
|
|
41
|
-
return render json: { error:
|
|
47
|
+
return render json: { error: RUN_NOT_FOUND }, status: :not_found unless run
|
|
42
48
|
|
|
43
49
|
render json: run.to_h
|
|
44
50
|
end
|
|
@@ -51,7 +57,7 @@ module Profiler
|
|
|
51
57
|
position = [(request.headers["Last-Event-ID"].presence || params[:position]).to_i, 0].max
|
|
52
58
|
result = Profiler::TestRunner.run_store.read_output(params[:id], position: position)
|
|
53
59
|
if result[:status] == "not_found"
|
|
54
|
-
render json: { error:
|
|
60
|
+
render json: { error: RUN_NOT_FOUND }, status: :not_found
|
|
55
61
|
return
|
|
56
62
|
end
|
|
57
63
|
|
|
@@ -75,7 +81,8 @@ module Profiler
|
|
|
75
81
|
if killed
|
|
76
82
|
head :no_content
|
|
77
83
|
else
|
|
78
|
-
|
|
84
|
+
known = Profiler::TestRunner.run_store.find(params[:id])
|
|
85
|
+
render json: { error: known ? "Run not running" : RUN_NOT_FOUND }, status: :not_found
|
|
79
86
|
end
|
|
80
87
|
end
|
|
81
88
|
|
|
@@ -22,6 +22,11 @@ module Profiler
|
|
|
22
22
|
serve("profiler.css", "text/css")
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
def icon
|
|
26
|
+
expires_in 1.day, public: true
|
|
27
|
+
send_file Profiler::Engine.root.join("app", "assets", "images", "icon.png"), type: "image/png", disposition: "inline"
|
|
28
|
+
end
|
|
29
|
+
|
|
25
30
|
private
|
|
26
31
|
|
|
27
32
|
def serve(name, content_type)
|
|
@@ -83,13 +83,13 @@ module Profiler
|
|
|
83
83
|
# Sets @resolved_storage to the storage that owns the profile so that
|
|
84
84
|
# secondary lookups (child jobs, parent, AJAX) go to the same node.
|
|
85
85
|
def resolve_profile(token)
|
|
86
|
-
# Local storage
|
|
86
|
+
# Local storage, fast path, covers master-owned profiles
|
|
87
87
|
if (profile = Profiler.storage.load(token))
|
|
88
88
|
@resolved_storage = Profiler.storage
|
|
89
89
|
return profile
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
-
# Cache hit
|
|
92
|
+
# Cache hit, skip fan-out if we already know which slave has this token
|
|
93
93
|
if (cached_slave = Profiler.token_cache.fetch(token))
|
|
94
94
|
entry = Profiler.slave_registry.all.find { |e| e[:name] == cached_slave }
|
|
95
95
|
if entry && entry[:status] == "online"
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
<html data-theme="<%= params[:theme].presence&.then { |t| %w[light dark].include?(t) ? t : 'dark' } || 'dark' %>">
|
|
3
3
|
<head>
|
|
4
4
|
<title>Rails Profiler</title>
|
|
5
|
+
<link rel="icon" type="image/png" href="/_profiler/assets/icon.png">
|
|
5
6
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
7
|
<%= csrf_meta_tags %>
|
|
7
8
|
<%= csp_meta_tag %>
|
|
@@ -11,7 +12,7 @@
|
|
|
11
12
|
<script src="/_profiler/assets/profiler.js" defer></script>
|
|
12
13
|
|
|
13
14
|
<style>
|
|
14
|
-
/* Embedded overrides
|
|
15
|
+
/* Embedded overrides, tighter layout, no page chrome */
|
|
15
16
|
body { overflow-y: auto; }
|
|
16
17
|
.container { padding: 16px 20px; }
|
|
17
18
|
</style>
|
data/config/routes.rb
CHANGED
|
@@ -26,6 +26,7 @@ Profiler::Engine.routes.draw do
|
|
|
26
26
|
get "assets/profiler-toolbar.js", to: "assets#toolbar_js"
|
|
27
27
|
get "assets/profiler.js", to: "assets#main_js"
|
|
28
28
|
get "assets/profiler.css", to: "assets#main_css"
|
|
29
|
+
get "assets/icon.png", to: "assets#icon"
|
|
29
30
|
|
|
30
31
|
get "test_runner", to: "test_runner#index"
|
|
31
32
|
|
|
@@ -173,7 +173,7 @@ module Profiler
|
|
|
173
173
|
app_root = app_root_path
|
|
174
174
|
|
|
175
175
|
# Partition frames: app frames vs GC frame
|
|
176
|
-
# Exclude Ruby meta-frames (<main>, <class:Foo>, #<Class:0x...> generated templates)
|
|
176
|
+
# Exclude Ruby meta-frames (<main>, <class:Foo>, #<Class:0x...> generated templates), noise
|
|
177
177
|
gc_frame_ids = frames_meta.each_with_object(Set.new) { |(id, f), s| s << id if f[:name] == GC_FRAME_NAME }
|
|
178
178
|
app_frame_ids = frames_meta.each_with_object(Set.new) do |(id, f), s|
|
|
179
179
|
s << id if f[:file]&.start_with?(app_root) && !f[:name].to_s.match?(/\A[<#]/)
|
|
@@ -310,7 +310,7 @@ module Profiler
|
|
|
310
310
|
@loop_warnings << {
|
|
311
311
|
key: key,
|
|
312
312
|
count: count,
|
|
313
|
-
message: "#{key} was called #{count} times in a single request
|
|
313
|
+
message: "#{key} was called #{count} times in a single request - possible send loop"
|
|
314
314
|
}
|
|
315
315
|
end
|
|
316
316
|
end
|
|
@@ -49,6 +49,8 @@ module Profiler
|
|
|
49
49
|
:track_console,
|
|
50
50
|
:apply_env_overrides_when_disabled, :restrict_storage_permissions,
|
|
51
51
|
:test_runner_allow_undiscovered_files, :allow_explain_outside_development,
|
|
52
|
+
:test_runner_kill_grace_seconds,
|
|
53
|
+
:test_runner_max_output_bytes, :test_runner_max_active_runs,
|
|
52
54
|
:track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
|
|
53
55
|
:compress_bodies, :compress_body_threshold, :max_captured_body_bytes, :max_captured_log_bytes,
|
|
54
56
|
:redact_sensitive_data, :filter_parameters, :env_allowlist,
|
|
@@ -113,6 +115,14 @@ module Profiler
|
|
|
113
115
|
# only runs in the development and test environments, even with enabled = true elsewhere:
|
|
114
116
|
# PostgreSQL's EXPLAIN ANALYZE runs the query. true allows it in every environment.
|
|
115
117
|
@allow_explain_outside_development = false
|
|
118
|
+
# Stopping a run sends TERM to its whole process group, then KILL after this many seconds
|
|
119
|
+
# if any process of the group is still there.
|
|
120
|
+
@test_runner_kill_grace_seconds = 5
|
|
121
|
+
# What a run keeps of its output, in bytes: the beginning, then the last quarter of this
|
|
122
|
+
# once it is exceeded, with a marker in between. nil keeps everything.
|
|
123
|
+
@test_runner_max_output_bytes = 5 * 1024 * 1024
|
|
124
|
+
# Runs whose test process has not ended yet; another one is refused. nil for no limit.
|
|
125
|
+
@test_runner_max_active_runs = 2
|
|
116
126
|
@track_mailers = true
|
|
117
127
|
@capture_mail_body = false
|
|
118
128
|
@sanitize_mailer_recipients = false
|
data/lib/profiler/mcp/server.rb
CHANGED
|
@@ -33,7 +33,7 @@ module Profiler
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def rack_app
|
|
36
|
-
# Memoized at class level
|
|
36
|
+
# Memoized at class level, survives route reloads in development
|
|
37
37
|
@rack_app ||= lambda do |env|
|
|
38
38
|
HttpGuard.call(env) || instance.http_transport.handle_request(Rack::Request.new(env))
|
|
39
39
|
end
|
|
@@ -230,7 +230,7 @@ module Profiler
|
|
|
230
230
|
),
|
|
231
231
|
define_tool(
|
|
232
232
|
name: "get_profile_mailers",
|
|
233
|
-
description: "Get detailed mailer activity for a profile: delivered emails, errors, and queued deliveries
|
|
233
|
+
description: "Get detailed mailer activity for a profile: delivered emails, errors, and queued deliveries - including email bodies when capture_mail_body is enabled.",
|
|
234
234
|
input_schema: {
|
|
235
235
|
properties: {
|
|
236
236
|
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
|
|
@@ -33,7 +33,7 @@ module Profiler
|
|
|
33
33
|
data = Profiler::ExplainRunner.run(token, query_index.to_i)
|
|
34
34
|
|
|
35
35
|
lines = []
|
|
36
|
-
lines << "# EXPLAIN ANALYZE
|
|
36
|
+
lines << "# EXPLAIN ANALYZE - Query ##{query_index}"
|
|
37
37
|
lines << "Adapter: `#{data[:adapter]}`\n"
|
|
38
38
|
|
|
39
39
|
if data[:format] == "json"
|
|
@@ -313,7 +313,7 @@ module Profiler
|
|
|
313
313
|
if view_data["views"] && !view_data["views"].empty?
|
|
314
314
|
lines << "### Templates"
|
|
315
315
|
view_data["views"].each do |view|
|
|
316
|
-
lines << "- `#{view['identifier']}`
|
|
316
|
+
lines << "- `#{view['identifier']}` - #{view['duration'].round(2)} ms"
|
|
317
317
|
end
|
|
318
318
|
lines << ""
|
|
319
319
|
end
|
|
@@ -321,7 +321,7 @@ module Profiler
|
|
|
321
321
|
if view_data["partials"] && !view_data["partials"].empty?
|
|
322
322
|
lines << "### Partials"
|
|
323
323
|
view_data["partials"].each do |partial|
|
|
324
|
-
lines << "- `#{partial['identifier']}`
|
|
324
|
+
lines << "- `#{partial['identifier']}` - #{partial['duration'].round(2)} ms"
|
|
325
325
|
end
|
|
326
326
|
lines << ""
|
|
327
327
|
end
|
|
@@ -343,7 +343,7 @@ module Profiler
|
|
|
343
343
|
lines << "### Cache Reads"
|
|
344
344
|
cache_data["reads"].each do |op|
|
|
345
345
|
hit_label = op["hit"] ? "HIT" : "MISS"
|
|
346
|
-
lines << "- [#{hit_label}] `#{op['key']}`
|
|
346
|
+
lines << "- [#{hit_label}] `#{op['key']}` - #{op['duration'].round(2)} ms"
|
|
347
347
|
end
|
|
348
348
|
lines << ""
|
|
349
349
|
end
|
|
@@ -351,7 +351,7 @@ module Profiler
|
|
|
351
351
|
if cache_data["writes"] && !cache_data["writes"].empty?
|
|
352
352
|
lines << "### Cache Writes"
|
|
353
353
|
cache_data["writes"].each do |op|
|
|
354
|
-
lines << "- `#{op['key']}`
|
|
354
|
+
lines << "- `#{op['key']}` - #{op['duration'].round(2)} ms"
|
|
355
355
|
end
|
|
356
356
|
lines << ""
|
|
357
357
|
end
|
|
@@ -359,7 +359,7 @@ module Profiler
|
|
|
359
359
|
if cache_data["deletes"] && !cache_data["deletes"].empty?
|
|
360
360
|
lines << "### Cache Deletes"
|
|
361
361
|
cache_data["deletes"].each do |op|
|
|
362
|
-
lines << "- `#{op['key']}`
|
|
362
|
+
lines << "- `#{op['key']}` - #{op['duration'].round(2)} ms"
|
|
363
363
|
end
|
|
364
364
|
lines << ""
|
|
365
365
|
end
|
|
@@ -378,7 +378,7 @@ module Profiler
|
|
|
378
378
|
if ajax_data["requests"] && !ajax_data["requests"].empty?
|
|
379
379
|
lines << "### Request List"
|
|
380
380
|
ajax_data["requests"].each do |req|
|
|
381
|
-
lines << "- **#{req['method']} #{req['path']}**
|
|
381
|
+
lines << "- **#{req['method']} #{req['path']}** - #{req['status']} - #{req['duration'].round(2)} ms (token: #{req['token']})"
|
|
382
382
|
end
|
|
383
383
|
lines << ""
|
|
384
384
|
end
|
|
@@ -402,7 +402,7 @@ module Profiler
|
|
|
402
402
|
http_data["requests"].each_with_index do |req, index|
|
|
403
403
|
flag = req["duration"] >= threshold ? " [SLOW]" : ""
|
|
404
404
|
err = req["status"] >= 400 || req["status"] == 0 ? " [ERROR]" : ""
|
|
405
|
-
lines << "\n**#{index + 1}. #{req['method']} #{req['url']}**
|
|
405
|
+
lines << "\n**#{index + 1}. #{req['method']} #{req['url']}** - #{req['status'] == 0 ? 'error' : req['status']} - #{req['duration'].round(2)} ms#{flag}#{err}"
|
|
406
406
|
|
|
407
407
|
if req["request_body"] && !req["request_body"].empty?
|
|
408
408
|
lines << "**Request Body:**"
|
|
@@ -45,7 +45,7 @@ module Profiler
|
|
|
45
45
|
run_started_at = Time.now
|
|
46
46
|
begin
|
|
47
47
|
run = Profiler::TestRunner::Runner.start(files: files, framework: framework)
|
|
48
|
-
rescue Profiler::TestRunner::InvalidFileError => e
|
|
48
|
+
rescue Profiler::TestRunner::InvalidFileError, Profiler::TestRunner::TooManyRunsError => e
|
|
49
49
|
return ::MCP::Tool::Response.new([{ type: "text", text: e.message }], error: true)
|
|
50
50
|
end
|
|
51
51
|
|
|
@@ -108,7 +108,9 @@ module Profiler
|
|
|
108
108
|
text += "| Field | Value |\n|-------|-------|\n"
|
|
109
109
|
text += "| Run ID | `#{run_id}` |\n"
|
|
110
110
|
text += "| Status | **#{run_data["status"]}** |\n"
|
|
111
|
-
text += "| Exit code | #{run_data["exit_code"].inspect} |\n
|
|
111
|
+
text += "| Exit code | #{run_data["exit_code"].inspect} |\n"
|
|
112
|
+
text += "| Signal | #{run_data["signal"]} |\n" if run_data["signal"]
|
|
113
|
+
text += "\n"
|
|
112
114
|
text += "## Output\n```\n#{output.strip}\n```"
|
|
113
115
|
[{ type: "text", text: text }]
|
|
114
116
|
end
|
|
@@ -136,11 +138,12 @@ module Profiler
|
|
|
136
138
|
lines << "| Files | #{files.size} |"
|
|
137
139
|
lines << "| Status | **#{run.status}** |"
|
|
138
140
|
lines << "| Exit code | #{run.exit_code.inspect} |"
|
|
141
|
+
lines << "| Signal | #{run.signal} |" if run.signal
|
|
139
142
|
lines << "| Duration | #{run.to_h[:duration]&.round(0)}ms |"
|
|
140
143
|
|
|
141
144
|
if timed_out
|
|
142
145
|
lines << ""
|
|
143
|
-
lines << "> ⚠ Timed out
|
|
146
|
+
lines << "> ⚠ Timed out - run is still in progress. Use run ID `#{run.id}` to check later."
|
|
144
147
|
end
|
|
145
148
|
|
|
146
149
|
lines << "\n## Output (last #{output.length} chars)"
|
|
@@ -174,7 +174,7 @@ module Profiler
|
|
|
174
174
|
@nonce ? " nonce=\"#{ERB::Util.html_escape(@nonce)}\"" : ""
|
|
175
175
|
end
|
|
176
176
|
|
|
177
|
-
# Thermal design system
|
|
177
|
+
# Thermal design system, self-contained CSS for the injected toolbar.
|
|
178
178
|
# Variables are defined on #profiler-toolbar to avoid polluting the host app.
|
|
179
179
|
def toolbar_styles
|
|
180
180
|
<<~'CSS'
|
|
@@ -278,7 +278,7 @@ module Profiler
|
|
|
278
278
|
transition: color var(--pf-tf), background var(--pf-tf);
|
|
279
279
|
cursor: default;
|
|
280
280
|
}
|
|
281
|
-
/* Amber bottom indicator
|
|
281
|
+
/* Amber bottom indicator, springy easing */
|
|
282
282
|
#profiler-toolbar .profiler-toolbar-item::after {
|
|
283
283
|
content: '';
|
|
284
284
|
position: absolute;
|
data/lib/profiler/railtie.rb
CHANGED
|
@@ -117,7 +117,7 @@ module Profiler
|
|
|
117
117
|
end
|
|
118
118
|
|
|
119
119
|
initializer "profiler.start_cluster_client" do
|
|
120
|
-
# Check slave? inside on_load
|
|
120
|
+
# Check slave? inside on_load, the app's own initializers (config/initializers/profiler.rb)
|
|
121
121
|
# set master_url AFTER railtie initializers run, so the check must happen after_initialize.
|
|
122
122
|
ActiveSupport.on_load(:after_initialize) do
|
|
123
123
|
next unless Profiler.configuration.enabled
|
|
@@ -6,10 +6,18 @@ require_relative "../redaction"
|
|
|
6
6
|
|
|
7
7
|
module Profiler
|
|
8
8
|
module TestRunner
|
|
9
|
+
# Raised by RunStore#create when as many runs as allowed still have their test process.
|
|
10
|
+
class TooManyRunsError < StandardError; end
|
|
11
|
+
|
|
12
|
+
# The runs live in the memory of the process that started them: under a server running
|
|
13
|
+
# several processes (Puma in cluster mode), a run is only known to the worker that started it.
|
|
9
14
|
class RunStore
|
|
10
15
|
TTL = 3600 # 1 hour
|
|
16
|
+
# Finished runs kept at most, the oldest dropped first, on top of the TTL.
|
|
17
|
+
MAX_FINISHED_RUNS = 20
|
|
11
18
|
|
|
12
|
-
|
|
19
|
+
# signal: the name of the signal that ended the process ("SIGKILL"), nil when it exited.
|
|
20
|
+
Run = Struct.new(:id, :status, :pid, :started_at, :finished_at, :output_lines, :exit_code, :signal, :files, :framework, keyword_init: true) do
|
|
13
21
|
def to_h
|
|
14
22
|
super.merge(
|
|
15
23
|
started_at: started_at&.iso8601,
|
|
@@ -20,7 +28,9 @@ module Profiler
|
|
|
20
28
|
end
|
|
21
29
|
end
|
|
22
30
|
|
|
23
|
-
|
|
31
|
+
# signaled: the process was ended by a signal that did not come from the profiler (out of
|
|
32
|
+
# memory, a crash, another program), so the tests neither passed nor failed.
|
|
33
|
+
TERMINAL_STATUSES = %w[passed failed killed signaled error].freeze
|
|
24
34
|
NOT_FOUND = { chunks: [].freeze, status: "not_found", position: 0, finished: true }.freeze
|
|
25
35
|
|
|
26
36
|
def initialize
|
|
@@ -29,25 +39,51 @@ module Profiler
|
|
|
29
39
|
@held = Concurrent::Hash.new
|
|
30
40
|
@output_finished = Concurrent::Hash.new
|
|
31
41
|
@held_lock = Mutex.new
|
|
42
|
+
@output_limits = Concurrent::Hash.new
|
|
43
|
+
@active = Concurrent::Hash.new
|
|
44
|
+
@create_lock = Mutex.new
|
|
32
45
|
end
|
|
33
46
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
# max_active: the run is refused when that many runs still have their test process; a run
|
|
48
|
+
# counts from here until #release. max_output_bytes: what the run keeps of its output (see
|
|
49
|
+
# #keep_output). nil for no limit.
|
|
50
|
+
def create(files:, framework:, max_active: nil, max_output_bytes: nil)
|
|
51
|
+
@create_lock.synchronize do
|
|
52
|
+
if max_active && @active.size >= max_active
|
|
53
|
+
raise TooManyRunsError,
|
|
54
|
+
"#{@active.size} test runs are already running, the limit is #{max_active} " \
|
|
55
|
+
"(config.test_runner_max_active_runs). Wait for one to end, or stop it."
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
id = SecureRandom.hex(8)
|
|
59
|
+
run = Run.new(
|
|
60
|
+
id: id,
|
|
61
|
+
status: "pending",
|
|
62
|
+
pid: nil,
|
|
63
|
+
started_at: Time.now,
|
|
64
|
+
finished_at: nil,
|
|
65
|
+
output_lines: Concurrent::Array.new,
|
|
66
|
+
exit_code: nil,
|
|
67
|
+
signal: nil,
|
|
68
|
+
files: files,
|
|
69
|
+
framework: framework.to_s
|
|
70
|
+
)
|
|
71
|
+
cleanup_old_runs
|
|
72
|
+
@runs[id] = run
|
|
73
|
+
@locks[id] = { mutex: Mutex.new, cond: ConditionVariable.new }
|
|
74
|
+
@active[id] = true
|
|
75
|
+
if max_output_bytes
|
|
76
|
+
tail = max_output_bytes / 4
|
|
77
|
+
@output_limits[id] = { head: max_output_bytes - tail, tail: tail, kept: 0, after_head: 0, end: nil, end_bytes: 0 }
|
|
78
|
+
end
|
|
79
|
+
run
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The test process of the run ended: it no longer counts against max_active.
|
|
84
|
+
def release(id)
|
|
85
|
+
@active.delete(id)
|
|
86
|
+
nil
|
|
51
87
|
end
|
|
52
88
|
|
|
53
89
|
def find(id)
|
|
@@ -88,7 +124,7 @@ module Profiler
|
|
|
88
124
|
cut = text.bytesize - holdback
|
|
89
125
|
@held[id] = text.byteslice(cut, text.bytesize - cut)
|
|
90
126
|
shown = as_text(text.byteslice(0, cut))
|
|
91
|
-
run
|
|
127
|
+
keep_output(id, run, shown) unless shown.empty?
|
|
92
128
|
end
|
|
93
129
|
signal(id)
|
|
94
130
|
end
|
|
@@ -102,7 +138,8 @@ module Profiler
|
|
|
102
138
|
@held_lock.synchronize do
|
|
103
139
|
@output_finished[id] = true
|
|
104
140
|
rest = @held.delete(id)
|
|
105
|
-
run
|
|
141
|
+
keep_output(id, run, as_text(rest)) if rest && !rest.empty?
|
|
142
|
+
release_tail(id, run)
|
|
106
143
|
end
|
|
107
144
|
signal(id)
|
|
108
145
|
end
|
|
@@ -171,10 +208,64 @@ module Profiler
|
|
|
171
208
|
bytes.dup.force_encoding(Encoding::UTF_8).scrub
|
|
172
209
|
end
|
|
173
210
|
|
|
174
|
-
|
|
211
|
+
# Under a limit, the output is kept as it comes up to the head (the limit less a quarter),
|
|
212
|
+
# then a marker line is shown at once and only the last quarter is kept, in a buffer
|
|
213
|
+
# shown by #release_tail once the process printed everything: the summary of a test run is
|
|
214
|
+
# at its end. Lines only ever get added, so the positions the pages hold stay right.
|
|
215
|
+
# Called under @held_lock.
|
|
216
|
+
def keep_output(id, run, text)
|
|
217
|
+
limit = @output_limits[id]
|
|
218
|
+
return run.output_lines.push(text) unless limit
|
|
219
|
+
|
|
220
|
+
unless limit[:end]
|
|
221
|
+
room = limit[:head] - limit[:kept]
|
|
222
|
+
if text.bytesize <= room
|
|
223
|
+
limit[:kept] += text.bytesize
|
|
224
|
+
return run.output_lines.push(text)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# What fits, without the first bytes of a character the limit cuts in two.
|
|
228
|
+
head = text.byteslice(0, room).scrub("")
|
|
229
|
+
run.output_lines.push(head) unless head.empty?
|
|
230
|
+
run.output_lines.push("\n[Profiler] Output truncated: the run printed more than " \
|
|
231
|
+
"#{limit[:head]} bytes. The rest is not kept; its last " \
|
|
232
|
+
"#{limit[:tail]} bytes are shown once the run ends.\n")
|
|
233
|
+
limit[:kept] += head.bytesize
|
|
234
|
+
limit[:end] = []
|
|
235
|
+
text = text.byteslice(head.bytesize, text.bytesize - head.bytesize)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# The pieces of the end, the oldest dropped while the others hold enough without it.
|
|
239
|
+
limit[:after_head] += text.bytesize
|
|
240
|
+
limit[:end] << text
|
|
241
|
+
limit[:end_bytes] += text.bytesize
|
|
242
|
+
while limit[:end_bytes] - limit[:end].first.bytesize >= limit[:tail]
|
|
243
|
+
limit[:end_bytes] -= limit[:end].shift.bytesize
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Shows the end kept past the limit, and lets anything appended later (the profiler's own
|
|
248
|
+
# lines about the end of the process) through.
|
|
249
|
+
def release_tail(id, run)
|
|
250
|
+
limit = @output_limits.delete(id)
|
|
251
|
+
return unless limit && limit[:end]
|
|
252
|
+
|
|
253
|
+
tail = limit[:end].join
|
|
254
|
+
tail = tail.byteslice(tail.bytesize - limit[:tail], limit[:tail]) if tail.bytesize > limit[:tail]
|
|
255
|
+
# Dropping from the front can leave the last bytes of a character first.
|
|
256
|
+
tail = tail.byteslice(1, tail.bytesize - 1) while !tail.empty? && (tail.getbyte(0) & 0xC0) == 0x80
|
|
257
|
+
left_out = limit[:after_head] - tail.bytesize
|
|
258
|
+
run.output_lines.push("[Profiler] #{left_out} bytes left out. The last #{tail.bytesize} bytes " \
|
|
259
|
+
"of the output:\n")
|
|
260
|
+
run.output_lines.push(tail.scrub) unless tail.empty?
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def forget(id)
|
|
264
|
+
@runs.delete(id)
|
|
265
|
+
@locks.delete(id)
|
|
175
266
|
@held.delete(id)
|
|
176
267
|
@output_finished.delete(id)
|
|
177
|
-
|
|
268
|
+
@output_limits.delete(id)
|
|
178
269
|
end
|
|
179
270
|
|
|
180
271
|
def snapshot(run, position)
|
|
@@ -187,9 +278,13 @@ module Profiler
|
|
|
187
278
|
}
|
|
188
279
|
end
|
|
189
280
|
|
|
281
|
+
# Finished runs older than the TTL, and the oldest past MAX_FINISHED_RUNS, are dropped; a
|
|
282
|
+
# run whose process is still there (killed, still printing) is kept whatever its age.
|
|
190
283
|
def cleanup_old_runs
|
|
191
284
|
cutoff = Time.now - TTL
|
|
192
|
-
@runs.
|
|
285
|
+
finished = @runs.values.select { |r| r.finished_at && !@active.key?(r.id) }.sort_by(&:finished_at)
|
|
286
|
+
expired, recent = finished.partition { |r| r.finished_at < cutoff }
|
|
287
|
+
(expired + recent.first([recent.size - MAX_FINISHED_RUNS, 0].max)).each { |r| forget(r.id) }
|
|
193
288
|
end
|
|
194
289
|
end
|
|
195
290
|
|
|
@@ -21,24 +21,49 @@ module Profiler
|
|
|
21
21
|
|
|
22
22
|
# Every caller (the HTTP controller, the MCP run_tests tool) goes through here, so the
|
|
23
23
|
# selection is checked once, before any run is recorded or any process is started.
|
|
24
|
+
# Raises TooManyRunsError when config.test_runner_max_active_runs runs still have their
|
|
25
|
+
# test process.
|
|
24
26
|
def self.start(files:, framework:)
|
|
25
27
|
validate_files!(files, framework)
|
|
26
|
-
|
|
28
|
+
config = Profiler.configuration
|
|
29
|
+
run = Profiler::TestRunner.run_store.create(
|
|
30
|
+
files: files, framework: framework,
|
|
31
|
+
max_active: config.test_runner_max_active_runs,
|
|
32
|
+
max_output_bytes: config.test_runner_max_output_bytes
|
|
33
|
+
)
|
|
27
34
|
spawn_async(run)
|
|
28
35
|
run
|
|
29
36
|
end
|
|
30
37
|
|
|
38
|
+
# The test process leads its own process group (see run_process): TERM goes to the whole
|
|
39
|
+
# group, the processes rspec or rails started included, and KILL follows after
|
|
40
|
+
# config.test_runner_kill_grace_seconds for whatever is still there.
|
|
31
41
|
def self.kill(run_id)
|
|
32
42
|
run = Profiler::TestRunner.run_store.find(run_id)
|
|
33
43
|
return false unless run && run.pid && run.status == "running"
|
|
34
44
|
|
|
35
45
|
begin
|
|
36
|
-
Process.kill("TERM", run.pid)
|
|
37
|
-
Profiler::TestRunner.run_store.update(run_id, status: "killed", finished_at: Time.now)
|
|
38
|
-
true
|
|
46
|
+
Process.kill("TERM", -run.pid)
|
|
39
47
|
rescue Errno::ESRCH
|
|
40
48
|
# Process already exited
|
|
41
|
-
false
|
|
49
|
+
return false
|
|
50
|
+
end
|
|
51
|
+
Profiler::TestRunner.run_store.update(run_id, status: "killed", finished_at: Time.now)
|
|
52
|
+
kill_group_later(run.pid)
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The group exists as long as one of its processes does. Its id is the pid of the test
|
|
57
|
+
# process, which the system can give to a new process once the whole group is gone; that
|
|
58
|
+
# takes the pids to wrap around within the delay, and the new process would have to lead a
|
|
59
|
+
# group of its own.
|
|
60
|
+
def self.kill_group_later(pgid)
|
|
61
|
+
delay = Profiler.configuration.test_runner_kill_grace_seconds.to_f
|
|
62
|
+
Thread.new do
|
|
63
|
+
sleep delay
|
|
64
|
+
Process.kill("KILL", -pgid)
|
|
65
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
66
|
+
nil # the group ended on TERM
|
|
42
67
|
end
|
|
43
68
|
end
|
|
44
69
|
|
|
@@ -123,6 +148,8 @@ module Profiler
|
|
|
123
148
|
status: "error",
|
|
124
149
|
finished_at: Time.now
|
|
125
150
|
)
|
|
151
|
+
ensure
|
|
152
|
+
Profiler::TestRunner.run_store.release(run.id)
|
|
126
153
|
end
|
|
127
154
|
end
|
|
128
155
|
|
|
@@ -132,8 +159,9 @@ module Profiler
|
|
|
132
159
|
|
|
133
160
|
Profiler::TestRunner.run_store.update(run.id, status: "running", started_at: Time.now)
|
|
134
161
|
|
|
135
|
-
# unsetenv_others: the child gets exactly env, not the ENV of this process
|
|
136
|
-
|
|
162
|
+
# unsetenv_others: the child gets exactly env, not the ENV of this process. pgroup: the
|
|
163
|
+
# child leads a process group of its own, which kill stops as a whole.
|
|
164
|
+
IO.popen([env, *cmd, unsetenv_others: true, pgroup: true, err: [:child, :out]], "r") do |io|
|
|
137
165
|
Profiler::TestRunner.run_store.update(run.id, pid: io.pid)
|
|
138
166
|
|
|
139
167
|
begin
|
|
@@ -148,21 +176,38 @@ module Profiler
|
|
|
148
176
|
end
|
|
149
177
|
end
|
|
150
178
|
|
|
151
|
-
|
|
179
|
+
process_status = $?
|
|
180
|
+
exit_code = process_status.exitstatus # nil when a signal ended the process
|
|
181
|
+
signal = signal_name(process_status.termsig) if process_status.signaled?
|
|
152
182
|
# A run killed from the profiler stays killed, whatever the process answered to TERM.
|
|
153
183
|
status = if run.status == "killed" then "killed"
|
|
184
|
+
elsif signal then "signaled"
|
|
154
185
|
elsif exit_code == 0 then "passed"
|
|
155
186
|
else "failed"
|
|
156
187
|
end
|
|
157
188
|
|
|
189
|
+
if status == "signaled"
|
|
190
|
+
# Before the status: a page following the run stops at its terminal status.
|
|
191
|
+
Profiler::TestRunner.run_store.append_output(
|
|
192
|
+
run.id, "\n[Profiler] The test process was ended by #{signal} before it finished: " \
|
|
193
|
+
"the result of the tests is unknown.\n"
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
|
|
158
197
|
Profiler::TestRunner.run_store.update(
|
|
159
198
|
run.id,
|
|
160
199
|
status: status,
|
|
161
200
|
finished_at: Time.now,
|
|
162
|
-
exit_code: exit_code
|
|
201
|
+
exit_code: exit_code,
|
|
202
|
+
signal: signal
|
|
163
203
|
)
|
|
164
204
|
end
|
|
165
205
|
|
|
206
|
+
def self.signal_name(number)
|
|
207
|
+
name = Signal.signame(number)
|
|
208
|
+
name ? "SIG#{name}" : "signal #{number}"
|
|
209
|
+
end
|
|
210
|
+
|
|
166
211
|
def self.build_command(files, framework)
|
|
167
212
|
root = project_root
|
|
168
213
|
absolute_files = files.map { |f| File.join(root, f) }
|
data/lib/profiler/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-profiler
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.35.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sébastien Duplessy
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-17 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -115,6 +115,7 @@ files:
|
|
|
115
115
|
- app/assets/builds/profiler-toolbar.js
|
|
116
116
|
- app/assets/builds/profiler.css
|
|
117
117
|
- app/assets/builds/profiler.js
|
|
118
|
+
- app/assets/images/icon.png
|
|
118
119
|
- app/assets/javascript/profiler-ajax-interceptor.js
|
|
119
120
|
- app/controllers/profiler/api/ajax_controller.rb
|
|
120
121
|
- app/controllers/profiler/api/cluster_controller.rb
|