diff --git a/ddprof-lib/src/main/cpp/arguments.cpp b/ddprof-lib/src/main/cpp/arguments.cpp index b43f99fcc..0b4892101 100644 --- a/ddprof-lib/src/main/cpp/arguments.cpp +++ b/ddprof-lib/src/main/cpp/arguments.cpp @@ -410,6 +410,22 @@ Error Arguments::parse(const char *args) { } } + CASE("nosanity") + if (value != NULL) { + switch (value[0]) { + case 'n': // no + case 'f': // false + case '0': // 0 + _skip_sanity_checks = false; + break; + default: + _skip_sanity_checks = true; + } + } else { + // bare 'nosanity' with no value means skip checks + _skip_sanity_checks = true; + } + CASE("nativemem") _nativemem = value == NULL ? 0 : parseUnits(value, BYTES); if (_nativemem < 0) { diff --git a/ddprof-lib/src/main/cpp/arguments.h b/ddprof-lib/src/main/cpp/arguments.h index 16efe9c8b..08bd90e57 100644 --- a/ddprof-lib/src/main/cpp/arguments.h +++ b/ddprof-lib/src/main/cpp/arguments.h @@ -193,6 +193,7 @@ class Arguments { bool _lightweight; bool _enable_method_cleanup; bool _remote_symbolication; // Enable remote symbolication for native frames + bool _skip_sanity_checks; bool _jvmtistacks; // Delegate CPU/wall stack walks to HotSpot JFR RequestStackTrace extension bool _nativesocket; long _nativesocket_interval; // initial sampling period in nanoseconds; 0 = engine default @@ -234,6 +235,7 @@ class Arguments { _lightweight(false), _enable_method_cleanup(true), _remote_symbolication(false), + _skip_sanity_checks(false), _jvmtistacks(false), _nativesocket(false), _nativesocket_interval(0), diff --git a/ddprof-lib/src/main/cpp/os.h b/ddprof-lib/src/main/cpp/os.h index 6d50420ec..a3d028381 100644 --- a/ddprof-lib/src/main/cpp/os.h +++ b/ddprof-lib/src/main/cpp/os.h @@ -210,6 +210,8 @@ class OS { static bool getCpuDescription(char* buf, size_t size); static int getCpuCount(); + static int getCgroupCpuMillicores(); + static long getContainerMemoryLimit(); static u64 getProcessCpuTime(u64* utime, u64* stime); static u64 getTotalCpuTime(u64* utime, u64* stime); diff --git a/ddprof-lib/src/main/cpp/os_linux.cpp b/ddprof-lib/src/main/cpp/os_linux.cpp index ab59d8f19..d4d246fe8 100644 --- a/ddprof-lib/src/main/cpp/os_linux.cpp +++ b/ddprof-lib/src/main/cpp/os_linux.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -695,6 +696,319 @@ int OS::getCpuCount() { return sysconf(_SC_NPROCESSORS_ONLN); } +// Returns true if `controller` is present as an exact, comma-delimited +// token in `controllers` (e.g. "cpu" matches "cpu,cpuacct" but not +// "cpuacct" or "cpuset"; substring matching would give false positives +// since several v1 controller names share a "cpu" prefix). +static bool hasControllerToken(const char* controllers, const char* controller) { + size_t controller_len = strlen(controller); + const char* p = controllers; + while (*p != 0) { + const char* comma = strchr(p, ','); + size_t tok_len = (comma != NULL) ? (size_t)(comma - p) : strlen(p); + if (tok_len == controller_len && strncmp(p, controller, tok_len) == 0) { + return true; + } + if (comma == NULL) { + break; + } + p = comma + 1; + } + return false; +} + +// Resolves this process's own path within a cgroup hierarchy from +// /proc/self/cgroup, so that limits are read from the process's actual +// (possibly nested, e.g. "/user.slice/...") cgroup rather than from the +// hierarchy mount root. Pass an empty controller for the cgroup v2 unified +// hierarchy (format "0::/path"); pass a controller name (e.g. "cpu", +// "memory") to match a v1 hierarchy whose comma-separated controller list +// contains it (format "N:list:/path"). On success, copies the path (leading +// '/', no trailing '/', NUL-terminated) into `out` and returns true. +static bool getOwnCgroupPath(const char* controller, char* out, size_t out_size) { + int fd = open("/proc/self/cgroup", O_RDONLY); + if (fd == -1) { + return false; + } + char buf[2048]; + ssize_t r = read(fd, buf, sizeof(buf) - 1); + close(fd); + if (r <= 0) { + return false; + } + buf[r] = 0; + + char* line = buf; + while (line != NULL && *line != 0) { + char* nl = strchr(line, '\n'); + if (nl != NULL) { + *nl = 0; + } + char* c1 = strchr(line, ':'); + char* c2 = (c1 != NULL) ? strchr(c1 + 1, ':') : NULL; + if (c1 != NULL && c2 != NULL) { + *c2 = 0; + const char* controllers = c1 + 1; + const char* path = c2 + 1; + bool matches = (controller[0] == 0) ? (controllers[0] == 0) + : hasControllerToken(controllers, controller); + if (matches) { + size_t len = strlen(path); + if (len == 0 || len >= out_size) { + return false; + } + memcpy(out, path, len + 1); + return true; + } + } + line = (nl != NULL) ? nl + 1 : NULL; + } + return false; +} + +// Trims the last '/'-separated component from `path` (in place). Refuses to +// trim past `base_len` (the length of the hierarchy mount prefix, which is +// never itself a cgroup boundary to walk beyond). Returns false once the +// mount root has been reached. +static bool trimToParentCgroup(char* path, size_t base_len) { + if (strlen(path) <= base_len) { + return false; + } + char* slash = strrchr(path, '/'); + if (slash == NULL || (size_t)(slash - path) < base_len) { + return false; + } + *slash = 0; + return true; +} + +// Applies the most restrictive cpu.max quota found across this process's +// cgroup v2 group and all of its ancestors up to the mount root — a nested +// group can never be more permissive than a constrained ancestor. +static int walkCgroupV2CpuMillicores(char* path) { + size_t base_len = strlen("/sys/fs/cgroup"); + int best = -1; // unconstrained (or no data) so far + for (;;) { + char file[PATH_MAX]; + if ((size_t)snprintf(file, sizeof(file), "%s/cpu.max", path) < sizeof(file)) { + int fd = open(file, O_RDONLY); + if (fd != -1) { + char buf[64] = {0}; + ssize_t r = read(fd, buf, sizeof(buf) - 1); + close(fd); + if (r > 0 && strncmp(buf, "max", 3) != 0) { + long quota, period; + if (sscanf(buf, "%ld %ld", "a, &period) == 2 && period > 0) { + int mc = (int)(quota * 1000 / period); + if (best < 0 || mc < best) { + best = mc; + } + } + } + } + } + if (!trimToParentCgroup(path, base_len)) { + break; + } + } + return best; +} + +// Same ancestor-walk as walkCgroupV2CpuMillicores(), for the cgroup v1 CPU +// controller (separate quota/period files instead of a single "cpu.max"). +static int walkCgroupV1CpuMillicores(char* path) { + size_t base_len = strlen("/sys/fs/cgroup/cpu"); + int best = -1; + for (;;) { + long quota = -1; + char qfile[PATH_MAX]; + if ((size_t)snprintf(qfile, sizeof(qfile), "%s/cpu.cfs_quota_us", path) < sizeof(qfile)) { + int fd = open(qfile, O_RDONLY); + if (fd != -1) { + char buf[32] = {0}; + ssize_t r = read(fd, buf, sizeof(buf) - 1); + close(fd); + if (r > 0) { + quota = atol(buf); + } + } + } + if (quota > 0) { + long period = 100000; // default 100ms + char pfile[PATH_MAX]; + if ((size_t)snprintf(pfile, sizeof(pfile), "%s/cpu.cfs_period_us", path) < sizeof(pfile)) { + int fd = open(pfile, O_RDONLY); + if (fd != -1) { + char buf[32] = {0}; + ssize_t r = read(fd, buf, sizeof(buf) - 1); + close(fd); + if (r > 0) { + long p = atol(buf); + if (p > 0) period = p; + } + } + } + int mc = (int)(quota * 1000 / period); + if (best < 0 || mc < best) { + best = mc; + } + } + if (!trimToParentCgroup(path, base_len)) { + break; + } + } + return best; +} + +int OS::getCgroupCpuMillicores() { + char subpath[PATH_MAX]; + char path[PATH_MAX]; + + // Try cgroup v2 first, resolved from this process's own cgroup path. + if (getOwnCgroupPath("", subpath, sizeof(subpath))) { + size_t base_len = strlen("/sys/fs/cgroup"); + size_t sub_len = strlen(subpath); + if (base_len + sub_len < sizeof(path)) { + memcpy(path, "/sys/fs/cgroup", base_len); + memcpy(path + base_len, subpath, sub_len + 1); + + char leaf[PATH_MAX]; + if ((size_t)snprintf(leaf, sizeof(leaf), "%s/cpu.max", path) < sizeof(leaf)) { + int fd = open(leaf, O_RDONLY); + if (fd != -1) { + close(fd); + return walkCgroupV2CpuMillicores(path); + } + } + } + } + + // Fall back to cgroup v1, likewise resolved from the process's own path. + if (getOwnCgroupPath("cpu", subpath, sizeof(subpath))) { + const char* base = "/sys/fs/cgroup/cpu"; + size_t base_len = strlen(base); + size_t sub_len = strlen(subpath); + if (base_len + sub_len < sizeof(path)) { + memcpy(path, base, base_len); + memcpy(path + base_len, subpath, sub_len + 1); + + char leaf[PATH_MAX]; + if ((size_t)snprintf(leaf, sizeof(leaf), "%s/cpu.cfs_quota_us", path) < sizeof(leaf)) { + int fd = open(leaf, O_RDONLY); + if (fd != -1) { + close(fd); + return walkCgroupV1CpuMillicores(path); + } + } + } + } + + return -1; // unconstrained or unavailable +} + +// Applies the smallest (most restrictive) memory.max found across this +// process's cgroup v2 group and all of its ancestors up to the mount root. +static long walkCgroupV2MemoryLimit(char* path) { + size_t base_len = strlen("/sys/fs/cgroup"); + long best = -1; + for (;;) { + char file[PATH_MAX]; + if ((size_t)snprintf(file, sizeof(file), "%s/memory.max", path) < sizeof(file)) { + int fd = open(file, O_RDONLY); + if (fd != -1) { + char buf[32] = {0}; + ssize_t r = read(fd, buf, sizeof(buf) - 1); + close(fd); + if (r > 0 && strncmp(buf, "max", 3) != 0) { + long limit = atol(buf); + if (limit > 0 && (best < 0 || limit < best)) { + best = limit; + } + } + } + } + if (!trimToParentCgroup(path, base_len)) { + break; + } + } + return best; +} + +// Same ancestor-walk as walkCgroupV2MemoryLimit(), for the cgroup v1 memory +// controller. +static long walkCgroupV1MemoryLimit(char* path) { + size_t base_len = strlen("/sys/fs/cgroup/memory"); + long best = -1; + for (;;) { + char file[PATH_MAX]; + if ((size_t)snprintf(file, sizeof(file), "%s/memory.limit_in_bytes", path) < sizeof(file)) { + int fd = open(file, O_RDONLY); + if (fd != -1) { + char buf[32] = {0}; + ssize_t r = read(fd, buf, sizeof(buf) - 1); + close(fd); + if (r > 0) { + long limit = atol(buf); + // A limit of 9223372036854771712 (LLONG_MAX rounded) means unconstrained. + if (limit > 0 && limit < 0x7ffffffffffff000L && (best < 0 || limit < best)) { + best = limit; + } + } + } + } + if (!trimToParentCgroup(path, base_len)) { + break; + } + } + return best; +} + +long OS::getContainerMemoryLimit() { + char subpath[PATH_MAX]; + char path[PATH_MAX]; + + // Try cgroup v2 first, resolved from this process's own cgroup path. + if (getOwnCgroupPath("", subpath, sizeof(subpath))) { + size_t base_len = strlen("/sys/fs/cgroup"); + size_t sub_len = strlen(subpath); + if (base_len + sub_len < sizeof(path)) { + memcpy(path, "/sys/fs/cgroup", base_len); + memcpy(path + base_len, subpath, sub_len + 1); + + char leaf[PATH_MAX]; + if ((size_t)snprintf(leaf, sizeof(leaf), "%s/memory.max", path) < sizeof(leaf)) { + int fd = open(leaf, O_RDONLY); + if (fd != -1) { + close(fd); + return walkCgroupV2MemoryLimit(path); + } + } + } + } + + // Fall back to cgroup v1, likewise resolved from the process's own path. + if (getOwnCgroupPath("memory", subpath, sizeof(subpath))) { + const char* base = "/sys/fs/cgroup/memory"; + size_t base_len = strlen(base); + size_t sub_len = strlen(subpath); + if (base_len + sub_len < sizeof(path)) { + memcpy(path, base, base_len); + memcpy(path + base_len, subpath, sub_len + 1); + + char leaf[PATH_MAX]; + if ((size_t)snprintf(leaf, sizeof(leaf), "%s/memory.limit_in_bytes", path) < sizeof(leaf)) { + int fd = open(leaf, O_RDONLY); + if (fd != -1) { + close(fd); + return walkCgroupV1MemoryLimit(path); + } + } + } + } + + return -1; +} + u64 OS::getProcessCpuTime(u64* utime, u64* stime) { struct tms buf; clock_t real = times(&buf); diff --git a/ddprof-lib/src/main/cpp/os_macos.cpp b/ddprof-lib/src/main/cpp/os_macos.cpp index 0aee02704..3300d856d 100644 --- a/ddprof-lib/src/main/cpp/os_macos.cpp +++ b/ddprof-lib/src/main/cpp/os_macos.cpp @@ -376,6 +376,14 @@ int OS::getCpuCount() { return sysctlbyname("hw.logicalcpu", &cpu_count, &size, NULL, 0) == 0 ? cpu_count : 1; } +int OS::getCgroupCpuMillicores() { + return -1; // not applicable on macOS +} + +long OS::getContainerMemoryLimit() { + return -1; // not applicable on macOS +} + u64 OS::getProcessCpuTime(u64* utime, u64* stime) { struct tms buf; clock_t real = times(&buf); diff --git a/ddprof-lib/src/main/cpp/profiler.cpp b/ddprof-lib/src/main/cpp/profiler.cpp index 60aa659d8..d3f5870b3 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -40,6 +40,7 @@ #include "wallClock.h" #include "wallClockCounters.h" #include "frames.h" +#include "sanityCheck.h" #include #include @@ -1378,6 +1379,22 @@ Error Profiler::start(Arguments &args, bool reset) { return error; } + // Sanity checks run at most once per process, across start/stop cycles — + // record that the first start was handled regardless of _skip_sanity_checks, + // otherwise a first start with nosanity leaves sanity_checked false and the + // checks unexpectedly run (and can fail) on a later start without nosanity. + static Error sanity_result = Error::OK; + static bool sanity_checked = false; + if (!sanity_checked) { + sanity_checked = true; + if (!args._skip_sanity_checks) { + sanity_result = SanityChecker::runChecks(args); + } + } + if (sanity_result && !args._skip_sanity_checks) { + return sanity_result; + } + error = checkJvmCapabilities(); if (error) { return error; diff --git a/ddprof-lib/src/main/cpp/sanityCheck.cpp b/ddprof-lib/src/main/cpp/sanityCheck.cpp new file mode 100644 index 000000000..115413291 --- /dev/null +++ b/ddprof-lib/src/main/cpp/sanityCheck.cpp @@ -0,0 +1,151 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#include "sanityCheck.h" +#include "common.h" +#include "os.h" +#include "vmEntry.h" +#include "hotspot/vmStructs.h" +#include "hotspot/vmStructs.inline.h" + +// Returns the value of a size-typed JVM flag, or default_val if not found. +// ThreadStackSize is declared as `intx` on standard HotSpot builds, so the +// Intx type must be accepted here too — otherwise this call always falls +// back to default_val for that flag. +static size_t getVMSizeFlag(const char* name, size_t default_val) { + VMFlag* f = VMFlag::find(name, {VMFlag::Type::Uintx, VMFlag::Type::Size_t, + VMFlag::Type::Uint64_t, VMFlag::Type::Intx}); + if (f != NULL && f->addr() != NULL) { + return *static_cast(f->addr()); + } + return default_val; +} + +// Adds b to a, clamping to UINT64_MAX on overflow instead of wrapping. +static u64 addClamped(u64 a, u64 b) { + u64 sum = a + b; + return sum < a ? UINT64_MAX : sum; +} + +Error SanityChecker::runChecks(const Arguments& /*args*/) { + // Static buffer for error message — safe because runChecks is called under + // _state_lock and the result is cached as a static Error in profiler.cpp. + static char err_buf[1024]; + + // --- Gather all system info upfront --- + int logical_cpus = OS::getCpuCount(); + int cgroup_mc = OS::getCgroupCpuMillicores(); + long container_limit = OS::getContainerMemoryLimit(); + bool containerized = (cgroup_mc > 0 || container_limit > 0); + + // -1 means "unknown" (OS::getCpuCount() failed, and no cgroup CPU limit is + // in effect) — an unknown core count must not fail the check, since that + // would reject on an OS query error rather than an actual resource + // constraint. + int effective_cores = (logical_cpus > 0) ? logical_cpus : -1; + if (cgroup_mc > 0) { + int cgroup_cores = cgroup_mc / 1000; + if (effective_cores < 0 || cgroup_cores < effective_cores) { + effective_cores = cgroup_cores; + } + } + + const u64 OS_RESERVE = 128ULL * 1024 * 1024; + const u64 PROFILER_OVERHEAD = 64ULL * 1024 * 1024; + + u64 ram = OS::getRamSize(); + u64 upper = (ram > OS_RESERVE) ? (ram - OS_RESERVE) : 0; + if (container_limit > 0 && (u64)container_limit < upper) { + upper = (u64)container_limit; + } + + const size_t DEFAULT_METASPACE = 256ULL * 1024 * 1024; + const size_t DEFAULT_CODECACHE = 240ULL * 1024 * 1024; + const size_t DEFAULT_STACK_SIZE = 512ULL * 1024; + const int DEFAULT_THREAD_COUNT = 200; + + // VMFlag::find() walks the HotSpot VMStructs flag table, which does not + // exist on OpenJ9/Zing — the calls below would silently report a + // zero-byte heap and fall back to fixed guesses for the other regions. + // Skip the memory estimate entirely on those runtimes rather than fail + // (or pass) the check on numbers that don't reflect the actual JVM. + bool hotspot = !VM::isOpenJ9() && !VM::isZing(); + + size_t heap_max = hotspot ? getVMSizeFlag("MaxHeapSize", 0) : 0; + size_t metaspace_max = hotspot ? getVMSizeFlag("MaxMetaspaceSize", DEFAULT_METASPACE) : 0; + size_t codecache = hotspot ? getVMSizeFlag("ReservedCodeCacheSize", DEFAULT_CODECACHE) : 0; + size_t stack_size = hotspot ? getVMSizeFlag("ThreadStackSize", DEFAULT_STACK_SIZE / 1024) * 1024 : 0; + + // MaxMetaspaceSize defaults to unbounded (max_uintx) on standard HotSpot + // builds, so the flag is present and the default_val fallback above never + // triggers. The exact sentinel value isn't reliable to match against — + // debug builds align it down during ergonomics, leaving it astronomically + // large but not bit-identical to SIZE_MAX. Any "limit" larger than total + // available memory isn't a real limit, so normalize on that instead. + if (metaspace_max > upper) { + metaspace_max = DEFAULT_METASPACE; + } + + int thread_count = DEFAULT_THREAD_COUNT; + ProcessInfo info = {}; + if (OS::getBasicProcessInfo(OS::processId(), &info) && info.threads > 0) { + thread_count = info.threads; + } + + u64 gc_overhead = (u64)heap_max * 30 / 100; + u64 lower = (u64)heap_max; + lower = addClamped(lower, (u64)metaspace_max); + lower = addClamped(lower, (u64)codecache); + lower = addClamped(lower, gc_overhead); + lower = addClamped(lower, (u64)thread_count * (u64)stack_size); + lower = addClamped(lower, PROFILER_OVERHEAD); + + // --- Run checks --- + // Per DataDog/java-profiler#480, the profiler refuses to run with fewer + // than 1 core. An unknown core count (-1) never fails this check — see + // the effective_cores computation above. + bool cpu_fail = (effective_cores >= 0 && effective_cores < 1); + bool mem_fail = (hotspot && upper > 0 && lower > upper); + + if (!cpu_fail && !mem_fail) { + return Error::OK; + } + + if (cpu_fail) { + LOG_WARN("Sanity check failed: effective CPU count is %d (logical=%d, cgroup=%dmc).", + effective_cores, logical_cpus, cgroup_mc); + } + if (mem_fail) { + LOG_WARN("Sanity check failed: estimated memory requirement (%llu MB) exceeds available memory (%llu MB).", + (unsigned long long)(lower / (1024 * 1024)), + (unsigned long long)(upper / (1024 * 1024))); + } + + snprintf(err_buf, sizeof(err_buf), + "[sanity] cpu=%s,memory=%s," + "logical_cores=%d,cgroup_millicores=%d,effective_cores=%d," + "ram_mb=%llu,container_limit_mb=%lld,upper_mb=%llu,lower_mb=%llu," + "heap_mb=%llu,metaspace_mb=%llu,codecache_mb=%llu," + "gc_overhead_mb=%llu,threads=%d,stack_kb=%llu,profiler_mb=%llu," + "containerized=%s", + cpu_fail ? "fail" : "ok", + mem_fail ? "fail" : "ok", + logical_cpus, cgroup_mc, effective_cores, + (unsigned long long)(ram / (1024 * 1024)), + container_limit > 0 ? (long long)(container_limit / (1024 * 1024)) : -1LL, + (unsigned long long)(upper / (1024 * 1024)), + (unsigned long long)(lower / (1024 * 1024)), + (unsigned long long)(heap_max / (1024 * 1024)), + (unsigned long long)(metaspace_max / (1024 * 1024)), + (unsigned long long)(codecache / (1024 * 1024)), + (unsigned long long)(gc_overhead / (1024 * 1024)), + thread_count, + (unsigned long long)(stack_size / 1024), + (unsigned long long)(PROFILER_OVERHEAD / (1024 * 1024)), + containerized ? "true" : "false"); + return Error(err_buf); +} diff --git a/ddprof-lib/src/main/cpp/sanityCheck.h b/ddprof-lib/src/main/cpp/sanityCheck.h new file mode 100644 index 000000000..43f051586 --- /dev/null +++ b/ddprof-lib/src/main/cpp/sanityCheck.h @@ -0,0 +1,16 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef _SANITY_CHECK_H +#define _SANITY_CHECK_H + +#include "arguments.h" + +class SanityChecker { + public: + static Error runChecks(const Arguments& args); +}; + +#endif // _SANITY_CHECK_H diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/sanity/SanityCheckTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/sanity/SanityCheckTest.java new file mode 100644 index 000000000..033a88835 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/sanity/SanityCheckTest.java @@ -0,0 +1,78 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.sanity; + +import com.datadoghq.profiler.JavaProfiler; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +public class SanityCheckTest { + + private JavaProfiler profiler; + private Path jfrDump; + + private String startCommand(String extra) throws IOException { + Path rootDir = Paths.get("/tmp/recordings"); + Files.createDirectories(rootDir); + jfrDump = Files.createTempFile(rootDir, "sanity-check-test", ".jfr"); + String base = "start,cpu=10ms,jfr,file=" + jfrDump.toAbsolutePath(); + return extra == null || extra.isEmpty() ? base : base + "," + extra; + } + + @AfterEach + void cleanup() throws Exception { + if (profiler != null) { + try { + profiler.stop(); + } catch (IllegalStateException ignored) { + // already stopped or never started + } + } + if (jfrDump != null) { + Files.deleteIfExists(jfrDump); + } + } + + /** + * nosanity=true bypasses sanity checks; profiler must start successfully on any host. + */ + @Test + void nosanity_bypasses_checks() throws Exception { + profiler = JavaProfiler.getInstance(); + assertDoesNotThrow(() -> profiler.execute(startCommand("nosanity"))); + } + + /** + * The override flag works regardless of value form (bare keyword vs explicit true). + */ + @Test + void nosanity_explicit_true_bypasses_checks() throws Exception { + profiler = JavaProfiler.getInstance(); + assertDoesNotThrow(() -> profiler.execute(startCommand("nosanity=true"))); + } + + /** + * Sanity checks run at most once across start/stop cycles. + * After a successful start with checks enabled, subsequent starts do not re-run checks. + */ + @Test + void sanity_checks_run_once() throws Exception { + profiler = JavaProfiler.getInstance(); + // First start with nosanity to guarantee success regardless of host resources. + profiler.execute(startCommand("nosanity")); + profiler.stop(); + // Second start (without nosanity) must not fail due to re-running checks — the + // static guard in the native layer ensures they only fire on the first invocation. + assertDoesNotThrow(() -> profiler.execute(startCommand(null))); + } +}