Run Shardok profiles until stopped (#8770)

This commit is contained in:
2026-07-24 09:05:05 -07:00
committed by GitHub
parent 71a6c9f791
commit 49185c103a
8 changed files with 110 additions and 50 deletions
@@ -2,6 +2,7 @@
#include <gperftools/profiler.h>
#include <algorithm>
#include <chrono>
#include <cstdlib>
#include <filesystem>
@@ -27,16 +28,22 @@ auto FileSize(const std::string& path) -> int64_t {
} // namespace
CpuProfiler::CpuProfiler(std::string profilePath, std::string executablePath)
CpuProfiler::CpuProfiler(
std::string profilePath,
std::string executablePath,
const std::chrono::milliseconds maxDuration,
const int64_t maxProfileSizeBytes)
: state_(std::make_shared<State>()),
executablePath_(std::move(executablePath)) {
state_->profilePath = std::move(profilePath);
state_->maxDuration = maxDuration;
state_->maxProfileSizeBytes = maxProfileSizeBytes;
setenv("CPUPROFILE_FREQUENCY", "100", 1);
}
CpuProfiler::~CpuProfiler() { Stop(); }
auto CpuProfiler::Start(const int32_t durationSeconds) -> std::optional<std::string> {
auto CpuProfiler::Start() -> std::optional<std::string> {
auto state = state_;
std::lock_guard lock(state->mutex);
if (state->recording) { return "A Shardok CPU profile is already recording"; }
@@ -52,15 +59,22 @@ auto CpuProfiler::Start(const int32_t durationSeconds) -> std::optional<std::str
}
state->recording = true;
state->durationSeconds = durationSeconds;
state->startedAtEpochMillis = EpochMillisNow();
const uint64_t generation = ++state->generation;
const auto deadline = std::chrono::steady_clock::now() + state->maxDuration;
const auto monitorInterval = std::min(std::chrono::milliseconds(1000), state->maxDuration);
std::thread([state = std::move(state), generation, durationSeconds]() {
std::this_thread::sleep_for(std::chrono::seconds(durationSeconds));
std::lock_guard autoStopLock(state->mutex);
if (state->recording && state->generation == generation) {
CpuProfiler::StopLocked(*state);
std::thread([state = std::move(state), generation, deadline, monitorInterval]() {
while (true) {
std::this_thread::sleep_for(monitorInterval);
std::lock_guard monitorLock(state->mutex);
if (!state->recording || state->generation != generation) { return; }
if (std::chrono::steady_clock::now() >= deadline ||
FileSize(state->profilePath) >= state->maxProfileSizeBytes) {
CpuProfiler::StopLocked(*state);
return;
}
}
}).detach();
@@ -89,7 +103,6 @@ auto CpuProfiler::Snapshot() const -> CpuProfileSnapshot {
return CpuProfileSnapshot{
.recording = state_->recording,
.profileAvailable = profileSize > 0,
.durationSeconds = state_->durationSeconds,
.startedAtEpochMillis = state_->startedAtEpochMillis,
.profileSizeBytes = profileSize,
};
@@ -1,6 +1,7 @@
#ifndef EAGLE0_SHARDOK_SERVER_CPU_PROFILER_HPP
#define EAGLE0_SHARDOK_SERVER_CPU_PROFILER_HPP
#include <chrono>
#include <cstdint>
#include <memory>
#include <mutex>
@@ -12,7 +13,6 @@ namespace shardok {
struct CpuProfileSnapshot {
bool recording;
bool profileAvailable;
int32_t durationSeconds;
int64_t startedAtEpochMillis;
int64_t profileSizeBytes;
};
@@ -22,10 +22,11 @@ private:
struct State {
std::mutex mutex;
bool recording = false;
int32_t durationSeconds = 0;
int64_t startedAtEpochMillis = 0;
uint64_t generation = 0;
std::string profilePath;
std::chrono::milliseconds maxDuration;
int64_t maxProfileSizeBytes;
};
std::shared_ptr<State> state_;
@@ -35,16 +36,20 @@ private:
public:
static constexpr int32_t kSampleFrequencyHz = 100;
static constexpr std::chrono::milliseconds kMaxDuration = std::chrono::hours(1);
static constexpr int64_t kMaxProfileSizeBytes = 100 * 1024 * 1024;
explicit CpuProfiler(
std::string profilePath = "/tmp/shardok-admin.prof",
std::string executablePath = "/proc/self/exe");
std::string executablePath = "/proc/self/exe",
std::chrono::milliseconds maxDuration = kMaxDuration,
int64_t maxProfileSizeBytes = kMaxProfileSizeBytes);
~CpuProfiler();
CpuProfiler(const CpuProfiler&) = delete;
auto operator=(const CpuProfiler&) -> CpuProfiler& = delete;
auto Start(int32_t durationSeconds) -> std::optional<std::string>;
auto Start() -> std::optional<std::string>;
void Stop();
void Flush();
[[nodiscard]] auto Snapshot() const -> CpuProfileSnapshot;
@@ -81,7 +81,6 @@ void PopulateCpuProfileStatus(
CpuProfileStatusResponse *response) {
response->set_recording(snapshot.recording);
response->set_profile_available(snapshot.profileAvailable);
response->set_duration_seconds(snapshot.durationSeconds);
response->set_started_at_epoch_millis(snapshot.startedAtEpochMillis);
response->set_profile_size_bytes(snapshot.profileSizeBytes);
response->set_sample_frequency_hz(CpuProfiler::kSampleFrequencyHz);
@@ -148,17 +147,11 @@ auto EagleInterfaceImpl::GetCpuProfileStatus(
auto EagleInterfaceImpl::StartCpuProfile(
ServerContext *context,
const StartCpuProfileRequest *request,
const StartCpuProfileRequest * /*request*/,
CpuProfileStatusResponse *response) -> Status {
if (auto authStatus = tokenValidator_.ValidateOrStatus(context)) { return *authStatus; }
const int32_t durationSeconds = request->duration_seconds();
if (durationSeconds != 30 && durationSeconds != 60 && durationSeconds != 120) {
return Status(
StatusCode::INVALID_ARGUMENT,
"Profile duration must be 30, 60, or 120 seconds");
}
if (const auto error = cpuProfiler_.Start(durationSeconds)) {
if (const auto error = cpuProfiler_.Start()) {
return Status(StatusCode::FAILED_PRECONDITION, *error);
}
@@ -10,7 +10,6 @@ import (
"log"
"net/http"
"os"
"strconv"
"time"
shardokpb "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/common/shardok_internal_interface"
@@ -29,7 +28,6 @@ var (
type shardokProfileStatusResponse struct {
Recording bool `json:"recording"`
ProfileAvailable bool `json:"profile_available"`
DurationSeconds int32 `json:"duration_seconds,omitempty"`
StartedAtEpochMs int64 `json:"started_at_epoch_millis,omitempty"`
ProfileSizeBytes int64 `json:"profile_size_bytes,omitempty"`
SampleFrequencyHz int32 `json:"sample_frequency_hz,omitempty"`
@@ -107,7 +105,6 @@ func profileStatusResponse(status *shardokpb.CpuProfileStatusResponse) shardokPr
return shardokProfileStatusResponse{
Recording: status.GetRecording(),
ProfileAvailable: status.GetProfileAvailable(),
DurationSeconds: status.GetDurationSeconds(),
StartedAtEpochMs: status.GetStartedAtEpochMillis(),
ProfileSizeBytes: status.GetProfileSizeBytes(),
SampleFrequencyHz: status.GetSampleFrequencyHz(),
@@ -146,19 +143,9 @@ func handleShardokProfileStart(w http.ResponseWriter, r *http.Request) {
return
}
duration, err := strconv.Atoi(r.URL.Query().Get("duration"))
if err != nil || (duration != 30 && duration != 60 && duration != 120) {
writeShardokProfileJSON(w, http.StatusBadRequest, shardokProfileStatusResponse{
Error: "Profile duration must be 30, 60, or 120 seconds",
})
return
}
ctx, cancel := shardokProfileContext(r.Context(), 5*time.Second)
defer cancel()
status, err := shardokProfileClient.StartCpuProfile(ctx, &shardokpb.StartCpuProfileRequest{
DurationSeconds: int32(duration),
})
status, err := shardokProfileClient.StartCpuProfile(ctx, &shardokpb.StartCpuProfileRequest{})
if err != nil {
writeShardokProfileJSON(w, http.StatusBadGateway, shardokProfileStatusResponse{Error: err.Error()})
return
@@ -15,7 +15,6 @@ func TestShardokProfileStatusResponse(t *testing.T) {
response := profileStatusResponse(&shardokpb.CpuProfileStatusResponse{
Recording: true,
ProfileAvailable: true,
DurationSeconds: 60,
StartedAtEpochMillis: 1234,
ProfileSizeBytes: 5678,
SampleFrequencyHz: 100,
@@ -24,7 +23,7 @@ func TestShardokProfileStatusResponse(t *testing.T) {
if !response.Recording || !response.ProfileAvailable {
t.Fatalf("unexpected status flags: %+v", response)
}
if response.DurationSeconds != 60 || response.SampleFrequencyHz != 100 {
if response.SampleFrequencyHz != 100 {
t.Fatalf("unexpected profile settings: %+v", response)
}
if response.StartedAtEpochMs != 1234 || response.ProfileSizeBytes != 5678 {
@@ -54,12 +54,10 @@
<dialog id="shardok-profile-dialog">
<article style="max-width: 440px;">
<h3>Record Shardok CPU Profile</h3>
<p>Choose a bounded recording duration. The recording stops automatically.</p>
<p>The recording runs until you stop it, or automatically stops after one hour or 100 MB.</p>
<footer style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button class="secondary outline" onclick="document.getElementById('shardok-profile-dialog').close()">Cancel</button>
<button class="secondary" onclick="startShardokProfile(30)">30 sec</button>
<button class="secondary" onclick="startShardokProfile(60)">60 sec</button>
<button onclick="startShardokProfile(120)">120 sec</button>
<button onclick="startShardokProfile()">Start</button>
</footer>
</article>
</dialog>
@@ -179,7 +177,7 @@
if (data.recording) {
container.innerHTML = `
<span class="jfr-status jfr-on">Shardok CPU: ON (${data.duration_seconds}s)</span>
<span class="jfr-status jfr-on">Shardok CPU: ON</span>
<button onclick="stopShardokProfile()" class="btn-small secondary">Stop</button>
`;
} else {
@@ -204,9 +202,9 @@
document.getElementById('shardok-profile-dialog').showModal();
}
function startShardokProfile(durationSeconds) {
function startShardokProfile() {
document.getElementById('shardok-profile-dialog').close();
htmx.ajax('POST', `/shardok-profile/start?duration=${durationSeconds}`, {
htmx.ajax('POST', '/shardok-profile/start', {
target: '#shardok-profile-controls',
swap: 'none'
});
@@ -40,7 +40,7 @@ service ShardokInternalInterface {
message CpuProfileStatusRequest {}
message StartCpuProfileRequest {
int32 duration_seconds = 1;
reserved 1;
}
message StopCpuProfileRequest {}
@@ -48,7 +48,7 @@ message StopCpuProfileRequest {}
message CpuProfileStatusResponse {
bool recording = 1;
bool profile_available = 2;
int32 duration_seconds = 3;
reserved 3;
int64 started_at_epoch_millis = 4;
int64 profile_size_bytes = 5;
int32 sample_frequency_hz = 6;
@@ -5,6 +5,7 @@
#include <chrono>
#include <filesystem>
#include <string>
#include <thread>
#include "src/main/cpp/net/eagle0/shardok/server/CpuProfiler.hpp"
@@ -20,7 +21,7 @@ namespace {
#define EAGLE0_ADDRESS_SANITIZER_ENABLED 1
#endif
TEST(CpuProfilerTest, RecordsBoundedProfileAndRejectsConcurrentStart) {
TEST(CpuProfilerTest, RecordsUntilStoppedAndRejectsConcurrentStart) {
#if defined(EAGLE0_ADDRESS_SANITIZER_ENABLED)
GTEST_SKIP() << "gperftools stack sampling is incompatible with ASan stack redzones";
#endif
@@ -32,9 +33,9 @@ TEST(CpuProfilerTest, RecordsBoundedProfileAndRejectsConcurrentStart) {
std::filesystem::remove(profilePath);
CpuProfiler profiler(profilePath, "/proc/self/exe");
ASSERT_FALSE(profiler.Start(30).has_value());
ASSERT_FALSE(profiler.Start().has_value());
EXPECT_TRUE(profiler.Snapshot().recording);
EXPECT_TRUE(profiler.Start(30).has_value());
EXPECT_TRUE(profiler.Start().has_value());
std::atomic<uint64_t> work = 0;
const auto workUntil = std::chrono::steady_clock::now() + std::chrono::milliseconds(250);
@@ -50,5 +51,69 @@ TEST(CpuProfilerTest, RecordsBoundedProfileAndRejectsConcurrentStart) {
std::filesystem::remove(profilePath);
}
TEST(CpuProfilerTest, AutomaticallyStopsAtDurationLimit) {
#if defined(EAGLE0_ADDRESS_SANITIZER_ENABLED)
GTEST_SKIP() << "gperftools stack sampling is incompatible with ASan stack redzones";
#endif
const std::string profilePath =
(std::filesystem::temp_directory_path() /
("shardok-cpu-profiler-duration-test-" + std::to_string(getpid()) + ".prof"))
.string();
std::filesystem::remove(profilePath);
CpuProfiler profiler(
profilePath,
"/proc/self/exe",
std::chrono::milliseconds(50),
CpuProfiler::kMaxProfileSizeBytes);
ASSERT_FALSE(profiler.Start().has_value());
const auto waitUntil = std::chrono::steady_clock::now() + std::chrono::seconds(2);
while (profiler.Snapshot().recording && std::chrono::steady_clock::now() < waitUntil) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
const CpuProfileSnapshot snapshot = profiler.Snapshot();
EXPECT_FALSE(snapshot.recording);
EXPECT_TRUE(snapshot.profileAvailable);
EXPECT_GT(snapshot.profileSizeBytes, 0);
std::filesystem::remove(profilePath);
}
TEST(CpuProfilerTest, AutomaticallyStopsAtProfileSizeLimit) {
#if defined(EAGLE0_ADDRESS_SANITIZER_ENABLED)
GTEST_SKIP() << "gperftools stack sampling is incompatible with ASan stack redzones";
#endif
const std::string profilePath =
(std::filesystem::temp_directory_path() /
("shardok-cpu-profiler-size-test-" + std::to_string(getpid()) + ".prof"))
.string();
std::filesystem::remove(profilePath);
CpuProfiler profiler(profilePath, "/proc/self/exe", std::chrono::seconds(10), 1);
ASSERT_FALSE(profiler.Start().has_value());
std::atomic<uint64_t> work = 0;
const auto workUntil = std::chrono::steady_clock::now() + std::chrono::milliseconds(50);
while (std::chrono::steady_clock::now() < workUntil) { work.fetch_add(1); }
profiler.Flush();
const auto waitUntil = std::chrono::steady_clock::now() + std::chrono::seconds(2);
while (profiler.Snapshot().recording && std::chrono::steady_clock::now() < waitUntil) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
const CpuProfileSnapshot snapshot = profiler.Snapshot();
EXPECT_FALSE(snapshot.recording);
EXPECT_TRUE(snapshot.profileAvailable);
EXPECT_GT(snapshot.profileSizeBytes, 1);
EXPECT_GT(work.load(), 0);
std::filesystem::remove(profilePath);
}
} // namespace
} // namespace shardok