This is an automated email from the ASF dual-hosted git repository.
virajjasani pushed a commit to branch branch-3
in repository https://gitbox.apache.org/repos/asf/hbase.git
The following commit(s) were added to refs/heads/branch-3 by this push:
new 623c852d7fe HBASE-29853 Move from async profiler binary to a maven
dependency (#7679)
623c852d7fe is described below
commit 623c852d7fef9fb392c6ec158ebd8d7b8f6b7ddd
Author: Aman Poonia <[email protected]>
AuthorDate: Thu Jun 25 06:06:10 2026 +0530
HBASE-29853 Move from async profiler binary to a maven dependency (#7679)
Signed-off-by: Andrew Purtell <[email protected]>
---
hbase-common/src/main/resources/hbase-default.xml | 17 +
hbase-http/pom.xml | 16 +
.../org/apache/hadoop/hbase/http/HttpServer.java | 43 +-
.../apache/hadoop/hbase/http/LibraryBackend.java | 63 ++
.../hadoop/hbase/http/ProfileOutputServlet.java | 4 +-
.../apache/hadoop/hbase/http/ProfileServlet.java | 699 ++++++++++++++++-----
.../apache/hadoop/hbase/http/ProfilerBackend.java | 171 +++++
.../hadoop/hbase/http/ProfilerCommandMapper.java | 199 ++++++
.../apache/hadoop/hbase/http/TestHttpServer.java | 61 ++
.../hadoop/hbase/http/TestProfileServlet.java | 615 ++++++++++++++++++
.../hadoop/hbase/http/TestProfilerBackend.java | 110 ++++
.../hbase/http/TestProfilerBackendIsolated.java | 163 +++++
.../hbase/http/TestProfilerCommandMapper.java | 265 ++++++++
.../src/main/resources/supplemental-models.xml | 14 +
pom.xml | 1 +
15 files changed, 2257 insertions(+), 184 deletions(-)
diff --git a/hbase-common/src/main/resources/hbase-default.xml
b/hbase-common/src/main/resources/hbase-default.xml
index 7eb264c837e..e921fd49942 100644
--- a/hbase-common/src/main/resources/hbase-default.xml
+++ b/hbase-common/src/main/resources/hbase-default.xml
@@ -1828,6 +1828,23 @@ possible configurations would overwhelm and obscure the
important.
servlets are jmx, metrics, prometheus
</description>
</property>
+ <property>
+ <name>hbase.profiler.enabled</name>
+ <value>true</value>
+ <description>
+ Whether the async-profiler /prof endpoint is enabled. When false, the
endpoint is
+ registered as a disabled stub (HTTP 500) regardless of whether the
async-profiler
+ library or ASYNC_PROFILER_HOME is present.
+
+ Security note: when the async-profiler Maven dependency is present on
the classpath
+ (built with -Pasync-profiler), the /prof endpoint is registered as a
privileged servlet
+ by default (this property defaults to true). Access is gated by
+ hadoop.security.instrumentation.requires.admin when Hadoop security is
enabled, but on
+ clusters without that flag any authenticated user can trigger profiling.
Set this property
+ to false to disable the endpoint on security-sensitive deployments
regardless of whether
+ the profiler library is present. Requires a service restart to take
effect.
+ </description>
+ </property>
<property>
<name>hbase.replication.rpc.codec</name>
<value>org.apache.hadoop.hbase.codec.KeyValueCodecWithTags</value>
diff --git a/hbase-http/pom.xml b/hbase-http/pom.xml
index b4bf291df0c..53cae353627 100644
--- a/hbase-http/pom.xml
+++ b/hbase-http/pom.xml
@@ -232,6 +232,12 @@
</exclusion>
</exclusions>
</dependency>
+ <dependency>
+ <groupId>tools.profiler</groupId>
+ <artifactId>async-profiler</artifactId>
+ <version>${async-profiler.version}</version>
+ <optional>true</optional>
+ </dependency>
</dependencies>
<build>
@@ -320,6 +326,16 @@
<!-- General Resources -->
</build>
<profiles>
+ <profile>
+ <id>async-profiler</id>
+ <dependencies>
+ <dependency>
+ <groupId>tools.profiler</groupId>
+ <artifactId>async-profiler</artifactId>
+ <version>${async-profiler.version}</version>
+ </dependency>
+ </dependencies>
+ </profile>
<profile>
<id>build-with-jdk11</id>
<activation>
diff --git
a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/HttpServer.java
b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/HttpServer.java
index fe2a9a48c21..49af0939d5a 100644
--- a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/HttpServer.java
+++ b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/HttpServer.java
@@ -26,7 +26,6 @@ import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
-import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
@@ -155,6 +154,8 @@ public class HttpServer implements FilterContainer {
"hbase.security.authentication.ui.config.protected";
public static final String HTTP_UI_NO_CACHE_ENABLE_KEY =
"hbase.http.filter.no-store.enable";
public static final boolean HTTP_PRIVILEGED_CONF_DEFAULT = false;
+ public static final String PROFILER_ENABLED_KEY = "hbase.profiler.enabled";
+ public static final boolean PROFILER_ENABLED_DEFAULT = true;
// The ServletContext attribute where the daemon Configuration
// gets stored.
@@ -871,21 +872,45 @@ public class HttpServer implements FilterContainer {
} else {
addUnprivilegedServlet("conf", "/conf", ConfServlet.class);
}
- final String asyncProfilerHome = ProfileServlet.getAsyncProfilerHome();
- if (asyncProfilerHome != null && !asyncProfilerHome.trim().isEmpty()) {
+
+ if (!conf.getBoolean(PROFILER_ENABLED_KEY, PROFILER_ENABLED_DEFAULT)) {
+ ServletHolder disabledHolder = new ServletHolder(new
ProfileServlet.DisabledServlet());
+
disabledHolder.setInitParameter(ProfileServlet.DisabledServlet.REASON_PARAM,
+ "The /prof endpoint is disabled by configuration (" +
PROFILER_ENABLED_KEY + "=false).");
+ addUnprivilegedServlet("/prof", disabledHolder);
+ LOG.info("Profiler disabled by configuration ({}=false). Disabling /prof
endpoint.",
+ PROFILER_ENABLED_KEY);
+ } else if (ProfileServlet.isAvailable()) {
addPrivilegedServlet("prof", "/prof", ProfileServlet.class);
+ ProfileServlet.ensureOutputDir();
Path tmpDir = Paths.get(ProfileServlet.OUTPUT_DIR);
- if (Files.notExists(tmpDir)) {
- Files.createDirectories(tmpDir);
- }
ServletContextHandler genCtx = new ServletContextHandler(contexts,
"/prof-output-hbase");
genCtx.addServlet(ProfileOutputServlet.class, "/*");
genCtx.setResourceBase(tmpDir.toAbsolutePath().toString());
genCtx.setDisplayName("prof-output-hbase");
+ // Must populate CONF_CONTEXT_ATTRIBUTE and ADMINS_ACL so
AdminAuthorizedFilter.init()
+ // and hasAdministratorAccess() can read them. Without this, conf and
acl are null and
+ // every /prof-output-hbase/* request throws NPE → 500 when
authentication is enabled.
+ setContextAttributes(genCtx, conf);
+ // Always wire AdminAuthorizedFilter — hasAdministratorAccess
short-circuits to true when
+ // hadoop.security.authorization=false, so this is a no-op when auth is
off and a real
+ // gate when it is on. Profiling output can contain row keys and
credential frames, so
+ // restricting it to admins matches the access control on the /prof
start endpoint.
+ FilterHolder filter = new FilterHolder(AdminAuthorizedFilter.class);
+ filter.setName(AdminAuthorizedFilter.class.getSimpleName());
+ FilterMapping fmap = new FilterMapping();
+ fmap.setPathSpec("/*");
+ fmap.setDispatches(FilterMapping.ALL);
+ fmap.setFilterName(AdminAuthorizedFilter.class.getSimpleName());
+ genCtx.getServletHandler().addFilter(filter, fmap);
} else {
- addUnprivilegedServlet("prof", "/prof",
ProfileServlet.DisabledServlet.class);
- LOG.info("ASYNC_PROFILER_HOME environment variable and
async.profiler.home system property "
- + "not specified. Disabling /prof endpoint.");
+ ServletHolder disabledHolder = new ServletHolder(new
ProfileServlet.DisabledServlet());
+
disabledHolder.setInitParameter(ProfileServlet.DisabledServlet.REASON_PARAM,
+ "The /prof endpoint is unavailable: the async-profiler library is not
on the classpath "
+ + "(build with -Pasync-profiler) and ASYNC_PROFILER_HOME is not
set.");
+ addUnprivilegedServlet("/prof", disabledHolder);
+ LOG.info("async-profiler not available (no library on classpath and
ASYNC_PROFILER_HOME "
+ + "not set). Disabling /prof endpoint.");
}
/* register metrics servlets */
diff --git
a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/LibraryBackend.java
b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/LibraryBackend.java
new file mode 100644
index 00000000000..89e4de088f0
--- /dev/null
+++ b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/LibraryBackend.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hadoop.hbase.http;
+
+import java.io.File;
+import java.io.IOException;
+import one.profiler.AsyncProfiler;
+import org.apache.yetus.audience.InterfaceAudience;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Backend that uses the async-profiler Java API (in-process). Requires the
+ * {@code tools.profiler:async-profiler} JAR and its native library on the
classpath.
+ * <p>
+ * This class is intentionally isolated in its own file so that the JVM never
loads
+ * {@code one.profiler.AsyncProfiler} on systems where the JAR is absent. It
is only instantiated
+ * reflectively from {@link ProfilerBackend#detect} after confirming the class
is resolvable.
+ */
[email protected]
+final class LibraryBackend implements ProfilerBackend {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(LibraryBackend.class);
+
+ @Override
+ public String executeStart(ProfileServlet.ProfileRequest request, File
outputFile)
+ throws IOException {
+ String cmd = ProfilerCommandMapper.toLibraryStartCommand(request);
+ return AsyncProfiler.getInstance().execute(cmd);
+ }
+
+ @Override
+ public String executeStop(ProfileServlet.ProfileRequest request, File
outputFile)
+ throws IOException {
+ String cmd = ProfilerCommandMapper.toLibraryStopCommand(request,
outputFile);
+ return AsyncProfiler.getInstance().execute(cmd);
+ }
+
+ @Override
+ public void destroy() {
+ try {
+ AsyncProfiler.getInstance().execute("stop");
+ } catch (Exception e) {
+ LOG.debug("Best-effort stop on servlet destroy failed (profiler may not
have been active)",
+ e);
+ }
+ }
+}
diff --git
a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfileOutputServlet.java
b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfileOutputServlet.java
index 12d0c462504..9bbc43dd198 100644
---
a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfileOutputServlet.java
+++
b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfileOutputServlet.java
@@ -47,8 +47,8 @@ public class ProfileOutputServlet extends DefaultServlet {
File requestedFile = new File(absoluteDiskPath);
// async-profiler version 1.4 writes 'Started [cpu] profiling' to output
file when profiler is
// running which gets replaced by final output. If final output is not
ready yet, the file size
- // will be <100 bytes (in all modes).
- if (requestedFile.length() < 100) {
+ // will be < ProfileServlet.PROF_OUTPUT_MIN_BYTES (in all modes).
+ if (requestedFile.length() < ProfileServlet.PROF_OUTPUT_MIN_BYTES) {
LOG.info(requestedFile + " is incomplete. Sending auto-refresh header.");
String refreshUrl = req.getRequestURI();
// Rebuild the query string (if we have one)
diff --git
a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfileServlet.java
b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfileServlet.java
index ac5a55138fc..55b05a85590 100644
--- a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfileServlet.java
+++ b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfileServlet.java
@@ -19,11 +19,13 @@ package org.apache.hadoop.hbase.http;
import java.io.File;
import java.io.IOException;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
-import java.util.ArrayList;
-import java.util.List;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.time.Instant;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Lock;
@@ -31,32 +33,54 @@ import java.util.concurrent.locks.ReentrantLock;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
-import org.apache.hadoop.hbase.util.ProcessUtils;
import org.apache.yetus.audience.InterfaceAudience;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import org.apache.hbase.thirdparty.com.google.common.base.Joiner;
-
/**
- * Servlet that runs async-profiler as web-endpoint. Following options from
async-profiler can be
- * specified as query paramater. // -e event profiling event:
cpu|alloc|lock|cache-misses etc. // -d
- * duration run profiling for 'duration' seconds (integer) // -i interval
sampling interval in
- * nanoseconds (long) // -j jstackdepth maximum Java stack depth (integer) //
-b bufsize frame
- * buffer size (long) // -t profile different threads separately // -s simple
class names instead of
- * FQN // -o fmt[,fmt...] output format:
summary|traces|flat|collapsed|svg|tree|jfr|html // --width
- * px SVG width pixels (integer) // --height px SVG frame height pixels
(integer) // --minwidth px
- * skip frames smaller than px (double) // --reverse generate stack-reversed
FlameGraph / Call tree
- * Example: - To collect 30 second CPU profile of current process (returns
FlameGraph svg) curl
- * "http://localhost:10002/prof" - To collect 1 minute CPU profile of current
process and output in
- * tree format (html) curl
"http://localhost:10002/prof?output=tree&duration=60" - To collect 30
- * second heap allocation profile of current process (returns FlameGraph svg)
curl
- * "http://localhost:10002/prof?event=alloc" - To collect lock contention
profile of current process
- * (returns FlameGraph svg) curl "http://localhost:10002/prof?event=lock"
Following event types are
- * supported (default is 'cpu') (NOTE: not all OS'es support all events) //
Perf events: // cpu //
- * page-faults // context-switches // cycles // instructions //
cache-references // cache-misses //
- * branches // branch-misses // bus-cycles // L1-dcache-load-misses //
LLC-load-misses //
- * dTLB-load-misses // mem:breakpoint // trace:tracepoint // Java events: //
alloc // lock
+ * Servlet that runs async-profiler as a web endpoint.
+ * <p>
+ * Query parameters:
+ * <ul>
+ * <li>{@code event} - profiling event: cpu|alloc|lock|cache-misses etc.
(default: cpu)</li>
+ * <li>{@code duration} - run profiling for N seconds, clamped to [1,
+ * {@value #MAX_DURATION_SECONDS}] (default: 10)</li>
+ * <li>{@code interval} - sampling interval in nanoseconds (long)</li>
+ * <li>{@code jstackdepth} - maximum Java stack depth (integer)</li>
+ * <li>{@code bufsize} - frame buffer size (long); honored only by
BinaryBackend</li>
+ * <li>{@code thread} - profile different threads separately (flag)</li>
+ * <li>{@code simple} - simple class names instead of FQN (flag)</li>
+ * <li>{@code output} - output format:
summary|traces|flat|collapsed|tree|jfr|html (default:
+ * html)</li>
+ * <li>{@code width} - flame graph width in pixels; honored only by
BinaryBackend</li>
+ * <li>{@code height} - flame graph frame height in pixels; honored only by
BinaryBackend</li>
+ * <li>{@code minwidth} - skip frames smaller than this width in pixels
(double)</li>
+ * <li>{@code reverse} - generate stack-reversed FlameGraph / Call tree
(flag)</li>
+ * <li>{@code pid} - target process ID; LibraryBackend only supports the
current JVM (returns 400
+ * for other PIDs), BinaryBackend supports external PIDs</li>
+ * <li>{@code refreshDelay} - extra seconds added to the auto-refresh delay
(integer)</li>
+ * <li>{@code last} - instead of starting a new session, redirect to the most
recently completed
+ * profiling result. Returns 404 if no result is cached yet. The last result
is kept in memory for
+ * the lifetime of the JVM.</li>
+ * </ul>
+ * <p>
+ * Examples:
+ *
+ * <pre>
+ * # 30-second CPU profile (default)
+ * curl "http://localhost:10002/prof"
+ *
+ * # 1-minute allocation profile in tree format
+ * curl
"http://localhost:10002/prof?event=alloc&output=tree&duration=60"
+ *
+ * # Redirect to the most recent profiling result
+ * curl "http://localhost:10002/prof?last"
+ * </pre>
+ * <p>
+ * Profiling is single-flight: only one session runs at a time. A second
request while a session is
+ * active returns HTTP 409 Conflict with the URL of the last completed result
(if any). Closing the
+ * browser tab does not cancel a running session — the stopper thread runs to
completion on the
+ * server.
*/
@InterfaceAudience.Private
public class ProfileServlet extends HttpServlet {
@@ -68,13 +92,39 @@ public class ProfileServlet extends HttpServlet {
private static final String ALLOWED_METHODS = "GET";
private static final String ACCESS_CONTROL_ALLOW_ORIGIN =
"Access-Control-Allow-Origin";
private static final String CONTENT_TYPE_TEXT = "text/plain; charset=utf-8";
- private static final String ASYNC_PROFILER_HOME_ENV = "ASYNC_PROFILER_HOME";
- private static final String ASYNC_PROFILER_HOME_SYSTEM_PROPERTY =
"async.profiler.home";
- private static final String OLD_PROFILER_SCRIPT = "profiler.sh";
- private static final String PROFILER_SCRIPT = "asprof";
private static final int DEFAULT_DURATION_SECONDS = 10;
+ static final int MAX_DURATION_SECONDS = 3600;
private static final AtomicInteger ID_GEN = new AtomicInteger(0);
static final String OUTPUT_DIR = System.getProperty("java.io.tmpdir") +
"/prof-output-hbase";
+ // ProfileOutputServlet considers a file complete when its size exceeds this
threshold.
+ // Error messages written to the output file must be padded past this limit.
+ static final int PROF_OUTPUT_MIN_BYTES = 100;
+
+ private static final String ASYNC_PROFILER_HOME_ENV = "ASYNC_PROFILER_HOME";
+ private static final String ASYNC_PROFILER_HOME_SYSTEM_PROPERTY =
"async.profiler.home";
+
+ /** Immutable record of a completed profiling session. */
+ static final class ProfileResult {
+ final String relativeUrl;
+ final String event;
+ final int durationSeconds;
+ final Instant completedAt;
+
+ ProfileResult(String relativeUrl, String event, int durationSeconds,
Instant completedAt) {
+ this.relativeUrl = relativeUrl;
+ this.event = event;
+ this.durationSeconds = durationSeconds;
+ this.completedAt = completedAt;
+ }
+ }
+
+ // Last completed profiling result — static so it survives servlet reloads
within the same JVM.
+ private static volatile ProfileResult lastResult = null;
+
+ // Cached backend detection result — computed once at class-load time so
that isAvailable()
+ // and the default constructor do not each pay the reflective detection cost.
+ private static final ProfilerBackend DETECTED_BACKEND =
+ ProfilerBackend.detect(getAsyncProfilerHome());
enum Event {
CPU("cpu"),
@@ -122,7 +172,7 @@ public class ProfileServlet extends HttpServlet {
TRACES,
FLAT,
COLLAPSED,
- // No SVG in 2.x asyncprofiler.
+ // SVG dropped in async-profiler 2.0 (HBASE-25685); remapped to HTML by
ProfilerCommandMapper.
SVG,
TREE,
JFR,
@@ -130,54 +180,178 @@ public class ProfileServlet extends HttpServlet {
HTML
}
- @edu.umd.cs.findbugs.annotations.SuppressWarnings(value =
"SE_TRANSIENT_FIELD_NOT_RESTORED",
+ // Static so a second ProfileServlet instance (servlet reload, test harness)
shares the same
+ // lock and flag as the first — both gate the same JVM-global AsyncProfiler
singleton.
+ private static final Lock profilerLock = new ReentrantLock();
+ private static volatile boolean profiling;
+ private final long currentPid = ProcessHandle.current().pid();
+ @edu.umd.cs.findbugs.annotations.SuppressWarnings(value = "SE_BAD_FIELD",
justification = "This class is never serialized nor restored.")
- private transient Lock profilerLock = new ReentrantLock();
- private transient volatile Process process;
- private String asyncProfilerHome;
- private Integer pid;
+ private final ProfilerBackend backend;
+
+ @InterfaceAudience.Private
+ public static final class ProfileRequest {
+ private final int duration;
+ private final Output output;
+ private final Event event;
+ private final Long interval;
+ private final Integer jstackDepth;
+ private final Long bufsize;
+ private final boolean thread;
+ private final boolean simple;
+ private final Integer width;
+ private final Integer height;
+ private final Double minwidth;
+ private final boolean reverse;
+ private final int refreshDelay;
+ private final Integer pid;
+
+ private ProfileRequest(int duration, Output output, Event event, Long
interval,
+ Integer jstackDepth, Long bufsize, boolean thread, boolean simple,
Integer width,
+ Integer height, Double minwidth, boolean reverse, int refreshDelay,
Integer pid) {
+ this.duration = duration;
+ this.output = output;
+ this.event = event;
+ this.interval = interval;
+ this.jstackDepth = jstackDepth;
+ this.bufsize = bufsize;
+ this.thread = thread;
+ this.simple = simple;
+ this.width = width;
+ this.height = height;
+ this.minwidth = minwidth;
+ this.reverse = reverse;
+ this.refreshDelay = refreshDelay;
+ this.pid = pid;
+ }
+
+ public int getDuration() {
+ return duration;
+ }
+
+ public Output getOutput() {
+ return output;
+ }
+
+ public Event getEvent() {
+ return event;
+ }
+
+ public Long getInterval() {
+ return interval;
+ }
+
+ public Integer getJstackDepth() {
+ return jstackDepth;
+ }
+
+ public Long getBufsize() {
+ return bufsize;
+ }
+
+ public boolean isThread() {
+ return thread;
+ }
+
+ public boolean isSimple() {
+ return simple;
+ }
+
+ public Integer getWidth() {
+ return width;
+ }
+
+ public Integer getHeight() {
+ return height;
+ }
+
+ public Double getMinwidth() {
+ return minwidth;
+ }
+
+ public boolean isReverse() {
+ return reverse;
+ }
+
+ public int getRefreshDelay() {
+ return refreshDelay;
+ }
+
+ public Integer getPid() {
+ return pid;
+ }
+ }
public ProfileServlet() {
- this.asyncProfilerHome = getAsyncProfilerHome();
- this.pid = ProcessUtils.getPid();
- LOG.info("Servlet process PID: " + pid + " asyncProfilerHome: " +
asyncProfilerHome);
+ this.backend = DETECTED_BACKEND;
+ LOG.info("ProfileServlet initialized with backend: {}",
+ backend != null ? backend.getClass().getSimpleName() : "none");
+ }
+
+ // visible for testing
+ ProfileServlet(ProfilerBackend backend) {
+ this.backend = backend;
}
@Override
- protected void doGet(final HttpServletRequest req, final HttpServletResponse
resp)
- throws IOException {
- if (!HttpServer.isInstrumentationAccessAllowed(getServletContext(), req,
resp)) {
- resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
- setResponseHeader(resp);
- resp.getWriter().write("Unauthorized: Instrumentation access is not
allowed!");
- return;
+ public void init() throws javax.servlet.ServletException {
+ super.init();
+ try {
+ ensureOutputDir();
+ } catch (IOException e) {
+ // Log and continue rather than failing daemon startup — a read-only or
full /tmp is not
+ // a reason to refuse to bring up Master/RegionServer. Profiling will
fail at request time.
+ LOG.warn("Failed to create profiler output directory {}; profiling
requests will fail. "
+ + "Check that java.io.tmpdir is writable.", OUTPUT_DIR, e);
}
+ }
- // make sure async profiler home is set
- if (asyncProfilerHome == null || asyncProfilerHome.trim().isEmpty()) {
- resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
- setResponseHeader(resp);
- resp.getWriter()
- .write("ASYNC_PROFILER_HOME env is not set.\n\n"
- + "Please ensure the prerequisites for the Profiler Servlet have
been installed and the\n"
- + "environment is properly configured. For more information please
see\n"
- + "https://hbase.apache.org/docs/profiler\n");
+ /**
+ * Creates {@link #OUTPUT_DIR} with permissions 0700 (owner-only) when the
filesystem supports
+ * POSIX permissions, so other local users cannot plant symlinks or read
profiling output. No-ops
+ * if the directory already exists. Silently skips the permission step on
non-POSIX filesystems
+ * (Windows, some container runtimes).
+ */
+ static void ensureOutputDir() throws IOException {
+ Path dir = Paths.get(OUTPUT_DIR);
+ if (Files.exists(dir)) {
return;
}
+ try {
+ Files.createDirectories(dir,
+
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------")));
+ } catch (UnsupportedOperationException e) {
+ // Non-POSIX filesystem (Windows, some container setups) — fall back to
plain mkdir.
+ Files.createDirectories(dir);
+ }
+ }
- // if pid is explicitly specified, use it else default to current process
- pid = getInteger(req, "pid", pid);
-
- // if pid is not specified in query param and if current process pid
cannot be determined
- if (pid == null) {
- resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
- setResponseHeader(resp);
- resp.getWriter()
- .write("'pid' query parameter unspecified or unable to determine PID
of current process.");
- return;
+ static String getAsyncProfilerHome() {
+ String home = System.getenv(ASYNC_PROFILER_HOME_ENV);
+ if (home == null || home.trim().isEmpty()) {
+ home = System.getProperty(ASYNC_PROFILER_HOME_SYSTEM_PROPERTY);
}
+ return home;
+ }
+
+ /**
+ * Returns true if a profiler backend was detected at class-load time.
Detection is a one-shot
+ * operation: a library added to the classpath after the JVM starts requires
a restart. A backend
+ * that resolved successfully here may still fail on first use if the native
binary is
+ * incompatible with the OS/kernel — that error surfaces at request time via
the
+ * {@code catch(Error | RuntimeException)} block in {@link #doGet}.
+ */
+ public static boolean isAvailable() {
+ return DETECTED_BACKEND != null;
+ }
- final int duration = getInteger(req, "duration", DEFAULT_DURATION_SECONDS);
+ public ProfileRequest parseProfileRequest(final HttpServletRequest req) {
+ // Note: when using in-process async-profiler Java API, we can only
profile this JVM.
+ // We keep the pid parameter for API compatibility, but do not support
external processes.
+ Integer requestedPid = getInteger(req, "pid", null);
+
+ final int duration = Math.min(
+ Math.max(getInteger(req, "duration", DEFAULT_DURATION_SECONDS), 1),
MAX_DURATION_SECONDS);
final Output output = getOutput(req);
final Event event = getEvent(req);
final Long interval = getLong(req, "interval");
@@ -189,109 +363,295 @@ public class ProfileServlet extends HttpServlet {
final Integer height = getInteger(req, "height", null);
final Double minwidth = getMinWidth(req);
final boolean reverse = req.getParameterMap().containsKey("reverse");
+ int refreshDelay = getInteger(req, "refreshDelay", 0);
+
+ return new ProfileRequest(duration, output, event, interval, jstackDepth,
bufsize, thread,
+ simple, width, height, minwidth, reverse, refreshDelay, requestedPid);
+ }
+
+ protected String executeStart(ProfileRequest request, File outputFile)
throws IOException {
+ return backend.executeStart(request, outputFile);
+ }
+
+ protected String executeStop(ProfileRequest request, File outputFile) throws
IOException {
+ return backend.executeStop(request, outputFile);
+ }
+
+ @Override
+ protected void doGet(final HttpServletRequest req, final HttpServletResponse
resp)
+ throws IOException {
+ if (!checkInstrumentationAccess(req, resp)) {
+ return;
+ }
+
+ // ?last — redirect to the most recent completed profiling result.
+ if (req.getParameterMap().containsKey("last")) {
+ ProfileResult last = lastResult;
+ if (last == null) {
+ writeError(resp, HttpServletResponse.SC_NOT_FOUND,
+ "No profiling results available yet. Run /prof to start a session.");
+ return;
+ }
+ // The output file may have been removed by tmpwatch / fs cleanup or a
JVM restart.
+ // Verify the file still exists before redirecting; otherwise clear the
stale pointer.
+ String fileName =
last.relativeUrl.substring(last.relativeUrl.lastIndexOf('/') + 1);
+ File outputFile = new File(OUTPUT_DIR, fileName);
+ if (!outputFile.exists()) {
+ lastResult = null;
+ writeError(resp, HttpServletResponse.SC_NOT_FOUND,
+ "The most recent profiling result (" + last.relativeUrl + ") no
longer exists "
+ + "(may have been cleaned up by the OS). Run /prof to start a new
session.");
+ return;
+ }
+ setResponseHeader(resp);
+ resp.sendRedirect(last.relativeUrl);
+ return;
+ }
+
+ final ProfileRequest request = parseProfileRequest(req);
+
+ // Reject non-positive PIDs regardless of backend — no valid process has
pid <= 0.
+ if (request.getPid() != null && request.getPid() <= 0) {
+ writeError(resp, HttpServletResponse.SC_BAD_REQUEST,
+ "Invalid pid " + request.getPid() + ": must be a positive process
ID.");
+ return;
+ }
+
+ // LibraryBackend can only profile the current JVM; BinaryBackend supports
external PIDs.
+ if (
+ request.getPid() != null && request.getPid().longValue() != currentPid
+ && backend instanceof LibraryBackend
+ ) {
+ LOG.warn("Rejected profiling request for PID {} (current PID: {}) — "
+ + "LibraryBackend only supports the current process",
request.getPid(), currentPid);
+ writeError(resp, HttpServletResponse.SC_BAD_REQUEST,
+ "The 'pid' parameter is only supported for the current process when
using the "
+ + "LibraryBackend (in-process async-profiler). Use
ASYNC_PROFILER_HOME to enable "
+ + "the BinaryBackend for cross-process profiling.");
+ return;
+ }
+
+ boolean locked = false;
+ boolean thisRequestSetProfiling = false;
+ boolean stopperStarted = false;
+ File outputFile = null;
+ try {
+ locked = profilerLock.tryLock(100, TimeUnit.MILLISECONDS);
+ if (!locked) {
+ LOG.info("Profiler lock busy; returning 409 immediately.");
+ writeError(resp, HttpServletResponse.SC_CONFLICT,
+ "Another instance of profiler is already running or the lock is
contended. "
+ + "Try again in a moment.");
+ return;
+ }
- if (process == null || !process.isAlive()) {
+ // Re-check under the lock to close the TOCTOU window.
+ if (profiling) {
+ StringBuilder msg = new StringBuilder("Another instance of profiler is
already running.");
+ ProfileResult last = lastResult;
+ if (last != null) {
+ msg.append(" Last result: ").append(last.relativeUrl).append("
(").append(last.event)
+ .append(", ").append(last.durationSeconds).append("s, completed ")
+ .append(last.completedAt).append("). Use /prof?last to view it.");
+ }
+ writeError(resp, HttpServletResponse.SC_CONFLICT, msg.toString());
+ return;
+ }
+
+ outputFile = createOutputFile(request);
+ final String relativeUrl = "/prof-output-hbase/" + outputFile.getName();
+ // Write the placeholder before starting the profiler. Using CREATE_NEW
(O_CREAT|O_EXCL)
+ // so a pre-planted symlink causes an immediate IOException rather than
following the link
+ // and truncating the symlink target (symlink-attack mitigation).
+ Files.write(outputFile.toPath(), new byte[0],
StandardOpenOption.CREATE_NEW);
+ executeStart(request, outputFile);
+ profiling = true;
+ thisRequestSetProfiling = true;
+
+ startStopperThread(request.getDuration(), request, outputFile,
relativeUrl);
+ stopperStarted = true;
+
+ writeAcceptedResponse(resp, request, relativeUrl);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ LOG.warn("Interrupted while acquiring profile lock.", e);
+ writeError(resp, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+ "Interrupted while acquiring profile lock.");
+ } catch (IOException | Error | RuntimeException e) {
+ // Catches:
+ // - IOException: AsyncProfiler.execute() throws IOException for invalid
agent commands
+ // - UnsatisfiedLinkError / other Error: native lib absent or
incompatible OS/kernel
+ // - IllegalStateException / IllegalArgumentException
(RuntimeException): double-start,
+ // unsupported event, rejected format from the profiler API
+ LOG.warn("Profiler failed to start or execute", e);
+ if (!resp.isCommitted()) {
+ writeError(resp, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+ "Profiler error: " + e.getMessage()
+ + ". Check that the async-profiler native library is compatible
with this OS/kernel.");
+ }
+ // If executeStart succeeded but startStopperThread failed (e.g.
t.start() threw
+ // OutOfMemoryError), the LibraryBackend AsyncProfiler instance is still
in "started" state
+ // with no stopper to drain it. A best-effort stop closes it so future
requests don't hit
+ // IllegalStateException("profiler already started").
+ if (thisRequestSetProfiling && !stopperStarted) {
+ try {
+ executeStop(request, outputFile);
+ } catch (Exception stopEx) {
+ LOG.warn("Best-effort stop after failed startStopperThread also
failed", stopEx);
+ }
+ }
+ // Delete the placeholder whenever the stopper thread was never started
— in that case
+ // no client received the output URL and the stopper will never write a
result to the file.
+ // Guard on !stopperStarted (not !thisRequestSetProfiling) to also cover
the rare path where
+ // executeStart succeeded but t.start() threw, leaving a 0-byte file
with no owner.
+ if (!stopperStarted && outputFile != null) {
+ try {
+ Files.deleteIfExists(outputFile.toPath());
+ } catch (IOException ioe) {
+ LOG.warn("Unable to delete orphan placeholder {}",
outputFile.getName(), ioe);
+ }
+ }
+ } finally {
+ // Only reset the profiling flag if THIS request was the one that set
it, and the stopper
+ // thread was never started (e.g. t.start() threw OutOfMemoryError).
Using a separate flag
+ // avoids incorrectly clearing profiling=true for a concurrently-running
session when this
+ // request exited early via the 409 conflict path.
+ if (thisRequestSetProfiling && !stopperStarted) {
+ profiling = false;
+ }
+ if (locked) {
+ profilerLock.unlock();
+ }
+ }
+ }
+
+ private void startStopperThread(final int durationSeconds, final
ProfileRequest request,
+ final File outputFile, final String relativeUrl) {
+ Thread t = new Thread(() -> {
+ boolean succeeded = false;
+ Throwable failure = null;
try {
- int lockTimeoutSecs = 3;
- if (profilerLock.tryLock(lockTimeoutSecs, TimeUnit.SECONDS)) {
+ TimeUnit.SECONDS.sleep(durationSeconds);
+ executeStop(request, outputFile);
+ // C5: executeStop may succeed but the profiler wrote nothing (e.g.
zero samples collected).
+ // ProfileOutputServlet polls until size >= PROF_OUTPUT_MIN_BYTES, so
pad a short/empty
+ // output file to unblock it rather than letting the browser
auto-refresh forever.
+ if (outputFile.length() < PROF_OUTPUT_MIN_BYTES) {
+ String pad = "Profiling completed but output was empty or very
short.";
+ while (pad.length() <= PROF_OUTPUT_MIN_BYTES) {
+ pad += " ";
+ }
+ Files.write(outputFile.toPath(),
pad.getBytes(StandardCharsets.UTF_8),
+ java.nio.file.StandardOpenOption.APPEND);
+ }
+ lastResult = new ProfileResult(relativeUrl,
request.getEvent().getInternalName(),
+ durationSeconds, Instant.now());
+ succeeded = true;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ failure = e;
+ LOG.warn("Profiler stopper thread interrupted; attempting best-effort
stop.", e);
+ // C3: The LibraryBackend AsyncProfiler is still in "started" state
after an interrupt.
+ // Best-effort stop so future /prof requests don't hit
IllegalStateException.
+ try {
+ executeStop(request, outputFile);
+ } catch (Exception stopEx) {
+ LOG.warn("Best-effort stop after stopper interrupt also failed",
stopEx);
+ }
+ } catch (Throwable e) {
+ failure = e;
+ LOG.warn("Profiler stop/dump failed", e);
+ } finally {
+ // C4: Reset profiling flag FIRST, before any I/O that could throw an
Error and skip it.
+ profiling = false;
+ // If the session did not complete successfully, pad the output file
to >100 bytes so
+ // ProfileOutputServlet's size check treats it as done and stops
auto-refreshing.
+ if (!succeeded && failure != null) {
try {
- File outputFile =
- new File(OUTPUT_DIR, "async-prof-pid-" + pid + "-" +
event.name().toLowerCase() + "-"
- + ID_GEN.incrementAndGet() + "." +
output.name().toLowerCase());
- Files.createDirectories(Paths.get(OUTPUT_DIR));
- List<String> cmd = new ArrayList<>();
- Path profilerScriptPath = Paths.get(asyncProfilerHome, "bin",
PROFILER_SCRIPT);
- if (!Files.exists(profilerScriptPath)) {
- LOG.info(
- "async-profiler script {} does not exist, fallback to use old
script {}(version <= 2.9).",
- PROFILER_SCRIPT, OLD_PROFILER_SCRIPT);
- profilerScriptPath = Paths.get(asyncProfilerHome,
OLD_PROFILER_SCRIPT);
- }
- cmd.add(profilerScriptPath.toString());
- cmd.add("-e");
- cmd.add(event.getInternalName());
- cmd.add("-d");
- cmd.add("" + duration);
- cmd.add("-o");
- cmd.add(output.name().toLowerCase());
- cmd.add("-f");
- cmd.add(outputFile.getAbsolutePath());
- if (interval != null) {
- cmd.add("-i");
- cmd.add(interval.toString());
- }
- if (jstackDepth != null) {
- cmd.add("-j");
- cmd.add(jstackDepth.toString());
+ String msg = (failure instanceof InterruptedException)
+ ? "Profiler session interrupted before stop completed."
+ : "Profiler stop/dump failed: " + failure.getMessage();
+ // PROF_OUTPUT_MIN_BYTES is checked by ProfileOutputServlet to
determine completion.
+ while (msg.length() <= PROF_OUTPUT_MIN_BYTES) {
+ msg += " ";
}
- if (bufsize != null) {
- cmd.add("-b");
- cmd.add(bufsize.toString());
- }
- if (thread) {
- cmd.add("-t");
- }
- if (simple) {
- cmd.add("-s");
- }
- if (width != null) {
- cmd.add("--width");
- cmd.add(width.toString());
- }
- if (height != null) {
- cmd.add("--height");
- cmd.add(height.toString());
- }
- if (minwidth != null) {
- cmd.add("--minwidth");
- cmd.add(minwidth.toString());
- }
- if (reverse) {
- cmd.add("--reverse");
- }
- cmd.add(pid.toString());
- process = ProcessUtils.runCmdAsync(cmd);
-
- // set response and set refresh header to output location
- setResponseHeader(resp);
- resp.setStatus(HttpServletResponse.SC_ACCEPTED);
- String relativeUrl = "/prof-output-hbase/" + outputFile.getName();
- resp.getWriter()
- .write("Started [" + event.getInternalName()
- + "] profiling. This page will automatically redirect to " +
relativeUrl + " after "
- + duration + " seconds. "
- + "If empty diagram and Linux 4.6+, see 'Basic Usage' section
on the Async "
- + "Profiler Home Page,
https://github.com/jvm-profiling-tools/async-profiler."
- + "\n\nCommand:\n" + Joiner.on(" ").join(cmd));
-
- // to avoid auto-refresh by ProfileOutputServlet, refreshDelay can
be specified
- // via url param
- int refreshDelay = getInteger(req, "refreshDelay", 0);
-
- // instead of sending redirect, set auto-refresh so that browsers
will refresh
- // with redirected url
- resp.setHeader("Refresh", (duration + refreshDelay) + ";" +
relativeUrl);
- resp.getWriter().flush();
- } finally {
- profilerLock.unlock();
+ Files.write(outputFile.toPath(),
msg.getBytes(StandardCharsets.UTF_8));
+ } catch (IOException ioe) {
+ LOG.warn("Unable to write profiler error to output file", ioe);
}
- } else {
- setResponseHeader(resp);
- resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
- resp.getWriter()
- .write("Unable to acquire lock. Another instance of profiler might
be running.");
- LOG.warn("Unable to acquire lock in " + lockTimeoutSecs
- + " seconds. Another instance of profiler might be running.");
}
- } catch (InterruptedException e) {
- LOG.warn("Interrupted while acquiring profile lock.", e);
- resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
- } else {
+ }, "ProfileServlet-stopper");
+ t.setDaemon(true);
+ t.start();
+ }
+
+ private boolean checkInstrumentationAccess(final HttpServletRequest req,
+ final HttpServletResponse resp) throws IOException {
+ if (!HttpServer.isInstrumentationAccessAllowed(getServletContext(), req,
resp)) {
+ resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
setResponseHeader(resp);
- resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
- resp.getWriter().write("Another instance of profiler is already
running.");
+ resp.getWriter().write("Unauthorized: Instrumentation access is not
allowed!");
+ return false;
}
+ return true;
+ }
+
+ @Override
+ public void destroy() {
+ if (backend != null) {
+ backend.destroy();
+ }
+ super.destroy();
+ }
+
+ private void writeError(final HttpServletResponse resp, final int status,
final String message)
+ throws IOException {
+ resp.setStatus(status);
+ setResponseHeader(resp);
+ resp.getWriter().write(message);
+ }
+
+ private File createOutputFile(final ProfileRequest request) throws
IOException {
+ final long pid = request.getPid() != null ? request.getPid().longValue() :
currentPid;
+ // Use the remapped file extension so that (e.g.) SVG→HTML remap is
reflected in the
+ // filename. toFileExtension is used here (not toFormatString) to avoid a
duplicate
+ // LOG.warn — the warning is emitted once by toLibraryStopCommand or
toCliCommand.
+ String ext = ProfilerCommandMapper.toFileExtension(request.getOutput());
+ File outputFile = new File(OUTPUT_DIR, "async-prof-pid-" + pid + "-"
+ + request.getEvent().name().toLowerCase() + "-" +
ID_GEN.incrementAndGet() + "." + ext);
+ return outputFile;
+ }
+
+ private void writeAcceptedResponse(final HttpServletResponse resp, final
ProfileRequest request,
+ final String relativeUrl) throws IOException {
+ setResponseHeader(resp);
+ resp.setStatus(HttpServletResponse.SC_ACCEPTED);
+ StringBuilder body = new StringBuilder();
+ body.append("Started [").append(request.getEvent().getInternalName())
+ .append("] profiling. This page will automatically redirect to
").append(relativeUrl)
+ .append(" after ").append(request.getDuration()).append(" seconds. ")
+ .append("If empty diagram and Linux 4.6+, see 'Basic Usage' section on
the Async ")
+ .append("Profiler Home Page,
https://github.com/jvm-profiling-tools/async-profiler.");
+ if (request.getOutput() == Output.SVG) {
+ body.append("\nNote: output=svg is not supported in async-profiler 2.0+;
serving html.");
+ }
+ if (backend instanceof LibraryBackend) {
+ if (request.getBufsize() != null) {
+ body.append("\nNote: bufsize= is not supported by the in-process
LibraryBackend"
+ + " (async-profiler 4.x) and was ignored."
+ + " Set ASYNC_PROFILER_HOME to use BinaryBackend if you need -b
support.");
+ }
+ if (request.getWidth() != null || request.getHeight() != null) {
+ body.append("\nNote: width= and height= are not supported by the
in-process LibraryBackend"
+ + " (async-profiler 4.x) and were ignored."
+ + " Set ASYNC_PROFILER_HOME to use BinaryBackend if you need
--width/--height support.");
+ }
+ }
+ resp.getWriter().write(body.toString());
+ resp.setHeader("Refresh",
+ (request.getDuration() + request.getRefreshDelay()) + ";" + relativeUrl);
+ resp.getWriter().flush();
}
private Integer getInteger(final HttpServletRequest req, final String param,
@@ -358,31 +718,24 @@ public class ProfileServlet extends HttpServlet {
response.setContentType(CONTENT_TYPE_TEXT);
}
- static String getAsyncProfilerHome() {
- String asyncProfilerHome = System.getenv(ASYNC_PROFILER_HOME_ENV);
- // if ENV is not set, see if
-Dasync.profiler.home=/path/to/async/profiler/home is set
- if (asyncProfilerHome == null || asyncProfilerHome.trim().isEmpty()) {
- asyncProfilerHome =
System.getProperty(ASYNC_PROFILER_HOME_SYSTEM_PROPERTY);
- }
-
- return asyncProfilerHome;
- }
-
public static class DisabledServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
+ /** Init-param key for the human-readable disable reason. */
+ static final String REASON_PARAM = "disabledReason";
+
@Override
protected void doGet(final HttpServletRequest req, final
HttpServletResponse resp)
throws IOException {
resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
setResponseHeader(resp);
- resp.getWriter()
- .write("The profiler servlet was disabled at startup.\n\n"
- + "Please ensure the prerequisites for the Profiler Servlet have
been installed and the\n"
- + "environment is properly configured. For more information please
see\n"
- + "https://hbase.apache.org/docs/profiler\n");
- return;
+ String reason = getInitParameter(REASON_PARAM);
+ if (reason == null || reason.isEmpty()) {
+ reason = "The profiler servlet was disabled at startup.";
+ }
+ resp.getWriter().write(reason + "\n\nFor more information please see "
+ + "https://hbase.apache.org/docs/profiler\n");
}
}
diff --git
a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfilerBackend.java
b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfilerBackend.java
new file mode 100644
index 00000000000..17b2eac80bc
--- /dev/null
+++ b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfilerBackend.java
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hadoop.hbase.http;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.apache.hadoop.hbase.util.ProcessUtils;
+import org.apache.yetus.audience.InterfaceAudience;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Abstraction over async-profiler execution. Implementations handle either
the in-process Java API
+ * ({@link LibraryBackend}, when the maven dependency is on the classpath) or
the external binary
+ * ({@link BinaryBackend}, when {@code ASYNC_PROFILER_HOME} is set).
+ * <p>
+ * This file deliberately contains no import of {@code
one.profiler.AsyncProfiler}. That import is
+ * isolated in {@link LibraryBackend} so that binary-only deployments never
trigger a
+ * {@code NoClassDefFoundError} when this class is loaded.
+ */
[email protected]
+interface ProfilerBackend {
+
+ Logger LOG = LoggerFactory.getLogger(ProfilerBackend.class);
+
+ /**
+ * Executes a profiling start command and returns the profiler's response.
+ */
+ String executeStart(ProfileServlet.ProfileRequest request, File outputFile)
throws IOException;
+
+ /**
+ * Executes a profiling stop/dump command.
+ */
+ String executeStop(ProfileServlet.ProfileRequest request, File outputFile)
throws IOException;
+
+ /**
+ * Cleans up any resources (e.g. kills a running process). Called on servlet
destroy.
+ */
+ default void destroy() {
+ }
+
+ /**
+ * Detects which backend is available. Prefers {@link LibraryBackend} over
{@link BinaryBackend}.
+ * Returns {@code null} if neither is available.
+ * <p>
+ * Detection runs <b>once</b> at class-load time (via {@code
DETECTED_BACKEND} in
+ * {@link ProfileServlet}). A library that becomes loadable after the JVM
starts requires a
+ * restart to be detected. A library that resolves at class-load but whose
native binary is
+ * incompatible with the OS/kernel will not surface the error here — it will
throw an
+ * {@code Error} or {@code RuntimeException} on the first {@code execute()}
call at request time.
+ * <p>
+ * When both the library and a binary home are available, {@link
LibraryBackend} is preferred and
+ * {@code ASYNC_PROFILER_HOME} is ignored.
+ * <p>
+ * {@link LibraryBackend} is instantiated reflectively so that its class —
and therefore
+ * {@code one.profiler.AsyncProfiler} — is never loaded on systems where the
JAR is absent.
+ */
+ static ProfilerBackend detect(String asyncProfilerHome) {
+ // 1. Try in-process Java API (optional maven dependency).
+ // Use Class.forName to probe without triggering a hard class-load of
LibraryBackend,
+ // which would pull in one.profiler.AsyncProfiler and fail on binary-only
systems.
+ try {
+ // Use the classloader that loaded this class so that
isolated-classloader tests
+ // (which block one.profiler.*) correctly see the library as absent.
+ ClassLoader cl = ProfilerBackend.class.getClassLoader();
+ Class.forName("one.profiler.AsyncProfiler", false, cl);
+ // AsyncProfiler resolved — now safe to load LibraryBackend through the
same loader
+ return (ProfilerBackend) Class
+ .forName("org.apache.hadoop.hbase.http.LibraryBackend", true,
cl).getDeclaredConstructor()
+ .newInstance();
+ } catch (UnsatisfiedLinkError | ExceptionInInitializerError |
ReflectiveOperationException e) {
+ // library not on classpath, native lib missing/incompatible, or static
initializer failure
+ LOG.warn("async-profiler library not available ({}); falling back to
BinaryBackend or"
+ + " DisabledServlet. Cause: {}", e.getClass().getSimpleName(),
e.getMessage());
+ } catch (Throwable e) {
+ // Guard against any other unexpected failure during reflective
instantiation so that a
+ // broken async-profiler installation falls back to DisabledServlet
rather than crashing
+ // the daemon at DETECTED_BACKEND static-field initialization time.
+ LOG.warn("Unexpected error during async-profiler backend detection; "
+ + "profiling will be unavailable. Cause: {}", e.toString());
+ }
+ // 2. Try external binary
+ if (asyncProfilerHome != null && !asyncProfilerHome.trim().isEmpty()) {
+ return new BinaryBackend(asyncProfilerHome);
+ }
+ return null;
+ }
+}
+
+/**
+ * Backend that invokes the async-profiler binary ({@code asprof} / {@code
profiler.sh}) as an
+ * external process. Requires {@code ASYNC_PROFILER_HOME} to be set.
+ */
[email protected]
+final class BinaryBackend implements ProfilerBackend {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(BinaryBackend.class);
+
+ private final String profilerHome;
+ private volatile Process process;
+
+ BinaryBackend(String profilerHome) {
+ this.profilerHome = profilerHome;
+ }
+
+ @Override
+ public String executeStart(ProfileServlet.ProfileRequest request, File
outputFile)
+ throws IOException {
+ // Prefer the caller-supplied ?pid= param; fall back to ProcessHandle
(Java 9+, always correct)
+ // rather than ProcessUtils.getPid() which reads the potentially-stale
JVM_PID env variable.
+ int pid = request.getPid() != null ? request.getPid() : (int)
ProcessHandle.current().pid();
+ List<String> cmd = ProfilerCommandMapper.toCliCommand(request, outputFile,
profilerHome, pid);
+ process = ProcessUtils.runCmdAsync(cmd);
+ return "";
+ }
+
+ @Override
+ public String executeStop(ProfileServlet.ProfileRequest request, File
outputFile)
+ throws IOException {
+ // The binary runs for the requested duration and exits on its own. Wait
for it so the
+ // profiling flag is not cleared before the process has actually finished
writing the output
+ // file — otherwise a new request could start a second asprof while the
first is still running.
+ Process p = process;
+ if (p != null) {
+ // C6: waitFor() without a timeout can block indefinitely if asprof
hangs (e.g. waiting for
+ // perf_event_open). Allow duration + 30 s slack; forcibly kill on
timeout so the stopper
+ // thread can exit and profiling=false is eventually restored.
+ int timeoutSecs = request.getDuration() + 30;
+ try {
+ boolean finished = p.waitFor(timeoutSecs, TimeUnit.SECONDS);
+ if (!finished) {
+ LOG.warn("async-profiler process did not exit within {} s; forcibly
killing it.",
+ timeoutSecs);
+ p.destroyForcibly();
+ throw new IOException(
+ "async-profiler process timed out after " + timeoutSecs + "
seconds and was killed.");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ LOG.warn("Interrupted while waiting for async-profiler process to
finish.", e);
+ }
+ }
+ return "";
+ }
+
+ @Override
+ public void destroy() {
+ Process p = process;
+ if (p != null && p.isAlive()) {
+ LOG.info("Destroying async-profiler process on servlet shutdown.");
+ p.destroy();
+ }
+ }
+}
diff --git
a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfilerCommandMapper.java
b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfilerCommandMapper.java
new file mode 100644
index 00000000000..2f6b2afe682
--- /dev/null
+++
b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfilerCommandMapper.java
@@ -0,0 +1,199 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hadoop.hbase.http;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.yetus.audience.InterfaceAudience;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Utility class that maps {@link ProfileServlet.ProfileRequest} to
async-profiler commands in both
+ * the in-process Java API format (comma-separated string) and the CLI format
(argument list).
+ */
[email protected]
+final class ProfilerCommandMapper {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(ProfilerCommandMapper.class);
+
+ private static final String PROFILER_SCRIPT = "asprof";
+ private static final String OLD_PROFILER_SCRIPT = "profiler.sh";
+
+ private ProfilerCommandMapper() {
+ }
+
+ /**
+ * Builds the start command string for the async-profiler Java API. Format:
+ * {@code
start,event=<event>[,interval=N][,jstackdepth=N][,threads][,simple]}
+ * <p>
+ * Note: {@code bufsize} is intentionally omitted — it is not a recognized
option in the
+ * async-profiler 4.x agent grammar and is silently ignored. It remains
supported by the
+ * BinaryBackend CLI path via {@code -b}.
+ */
+ static String toLibraryStartCommand(ProfileServlet.ProfileRequest request) {
+ StringBuilder sb = new StringBuilder("start");
+ sb.append(",event=").append(request.getEvent().getInternalName());
+ appendOption(sb, "interval", request.getInterval());
+ appendOption(sb, "jstackdepth", request.getJstackDepth());
+ if (request.isThread()) {
+ sb.append(",threads");
+ }
+ if (request.isSimple()) {
+ sb.append(",simple");
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Builds the stop command string for the async-profiler Java API. Format:
+ * {@code stop,file=<path>[,<format-token>][,minwidth=N][,reverse]}
+ * <p>
+ * In async-profiler 4.x the output format is derived from the file
extension for html/jfr, and
+ * via a bare token (e.g. {@code tree}, {@code flat}) for text-based
formats. The {@code format=}
+ * key is not recognized. {@code width} and {@code height} are also not
recognized by the 4.x
+ * agent grammar; they remain supported via the BinaryBackend CLI.
+ */
+ static String toLibraryStopCommand(ProfileServlet.ProfileRequest request,
File outputFile) {
+ StringBuilder sb = new StringBuilder("stop");
+ sb.append(",file=").append(outputFile.getAbsolutePath());
+ String fmt = toFormatString(request.getOutput());
+ // html/jfr: format derived from file extension by async-profiler 4.x — no
token needed.
+ // collapsed/tree/flat/traces/summary: must be passed as a bare token; the
.collapsed
+ // extension is NOT auto-detected by detectOutputFormat in 4.x.
+ if (!fmt.equals("html") && !fmt.equals("jfr")) {
+ sb.append(",").append(fmt);
+ }
+ appendOption(sb, "minwidth", request.getMinwidth());
+ if (request.isReverse()) {
+ sb.append(",reverse");
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Builds the CLI argument list for invoking the async-profiler binary
(asprof / profiler.sh).
+ * Locates the script under {@code <profilerHome>/bin/asprof}, falling back
to
+ * {@code <profilerHome>/profiler.sh} for older installations.
+ */
+ static List<String> toCliCommand(ProfileServlet.ProfileRequest request, File
outputFile,
+ String profilerHome, Integer pid) {
+ List<String> cmd = new ArrayList<>();
+ Path profilerScriptPath = Paths.get(profilerHome, "bin", PROFILER_SCRIPT);
+ if (!Files.exists(profilerScriptPath)) {
+ LOG.info("async-profiler script {} does not exist, falling back to
{}(version <= 2.9).",
+ PROFILER_SCRIPT, OLD_PROFILER_SCRIPT);
+ profilerScriptPath = Paths.get(profilerHome, OLD_PROFILER_SCRIPT);
+ }
+ cmd.add(profilerScriptPath.toString());
+ cmd.add("-e");
+ cmd.add(request.getEvent().getInternalName());
+ cmd.add("-d");
+ cmd.add(String.valueOf(request.getDuration()));
+ cmd.add("-o");
+ cmd.add(toFormatString(request.getOutput()));
+ cmd.add("-f");
+ cmd.add(outputFile.getAbsolutePath());
+ if (request.getInterval() != null) {
+ cmd.add("-i");
+ cmd.add(request.getInterval().toString());
+ }
+ if (request.getJstackDepth() != null) {
+ cmd.add("-j");
+ cmd.add(request.getJstackDepth().toString());
+ }
+ if (request.getBufsize() != null) {
+ cmd.add("-b");
+ cmd.add(request.getBufsize().toString());
+ }
+ if (request.isThread()) {
+ cmd.add("-t");
+ }
+ if (request.isSimple()) {
+ cmd.add("-s");
+ }
+ if (request.getWidth() != null) {
+ cmd.add("--width");
+ cmd.add(request.getWidth().toString());
+ }
+ if (request.getHeight() != null) {
+ cmd.add("--height");
+ cmd.add(request.getHeight().toString());
+ }
+ if (request.getMinwidth() != null) {
+ cmd.add("--minwidth");
+ cmd.add(request.getMinwidth().toString());
+ }
+ if (request.isReverse()) {
+ cmd.add("--reverse");
+ }
+ cmd.add(pid.toString());
+ return cmd;
+ }
+
+ /**
+ * Maps the {@link ProfileServlet.Output} enum to the format string used by
both backends. Logs a
+ * deprecation warning when SVG is requested (it was removed in
async-profiler 2.0, see
+ * HBASE-25685). Use {@link #toFileExtension} when only the file extension
is needed and the
+ * warning has already been emitted.
+ */
+ static String toFormatString(ProfileServlet.Output output) {
+ if (output == ProfileServlet.Output.SVG) {
+ LOG.warn("output=svg is obsolete (HBASE-25685); redirecting to html
(FlameGraph). "
+ + "Use output=html explicitly.");
+ }
+ return toFileExtension(output);
+ }
+
+ /**
+ * Maps the {@link ProfileServlet.Output} enum to a file extension / format
token without emitting
+ * any log warnings. SVG is silently remapped to {@code "html"}.
+ */
+ static String toFileExtension(ProfileServlet.Output output) {
+ switch (output) {
+ case SUMMARY:
+ return "summary";
+ case TRACES:
+ return "traces";
+ case FLAT:
+ return "flat";
+ case COLLAPSED:
+ return "collapsed";
+ case TREE:
+ return "tree";
+ case JFR:
+ return "jfr";
+ case SVG:
+ // SVG was dropped in async-profiler 2.0 (HBASE-25685) and hard-errors
in 4.x.
+ return "html";
+ case HTML:
+ default:
+ return "html";
+ }
+ }
+
+ private static void appendOption(StringBuilder sb, String key, Object value)
{
+ if (value != null) {
+ sb.append(',').append(key).append('=').append(value);
+ }
+ }
+}
diff --git
a/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestHttpServer.java
b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestHttpServer.java
index 8d5ffb188c1..0c60b77d2b2 100644
--- a/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestHttpServer.java
+++ b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestHttpServer.java
@@ -675,4 +675,65 @@ public class TestHttpServer extends
HttpServerFunctionalTest {
conn.connect();
assertEquals(HttpURLConnection.HTTP_FORBIDDEN, conn.getResponseCode());
}
+
+ @Test
+ public void testProfilerDisabledByConfig() throws Exception {
+ Configuration conf = new Configuration();
+ conf.setBoolean(HttpServer.PROFILER_ENABLED_KEY, false);
+ HttpServer myServer = new HttpServer.Builder().setName("test")
+ .addEndpoint(new
URI("http://localhost:0")).setFindPort(true).setConf(conf).build();
+ myServer.setAttribute(HttpServer.CONF_CONTEXT_ATTRIBUTE, conf);
+ myServer.start();
+ try {
+ URL profUrl =
+ new URL("http://" +
NetUtils.getHostPortString(myServer.getConnectorAddress(0)) + "/prof");
+ HttpURLConnection conn = (HttpURLConnection) profUrl.openConnection();
+ assertEquals(HttpURLConnection.HTTP_INTERNAL_ERROR,
conn.getResponseCode());
+ } finally {
+ myServer.stop();
+ }
+ }
+
+ /**
+ * Verify that /prof-output-hbase/* returns 200 (not 500) for a request when
authorization is
+ * disabled. A 500 here would indicate an NPE inside
AdminAuthorizedFilter.init() or
+ * hasAdministratorAccess(), which happens when setContextAttributes() is
not called on the output
+ * servlet's ServletContextHandler and conf/acl are null.
+ * <p>
+ * The authorization enforcement path (401/403 for non-admins) cannot be
tested at this level
+ * without a full SPNEGO/Kerberos or PseudoAuthentication filter stack,
which requires keytab
+ * configuration and is tested separately via testHasAdministratorAccess().
+ */
+ @Test
+ public void testProfilerOutputServletNoNpeWhenContextAttributesSet() throws
Exception {
+ Configuration conf = new Configuration();
+ // authorization=false: hasAdministratorAccess short-circuits to true, so
any request to
+ // /prof-output-hbase/* reaches the servlet. A 500 response means NPE from
null conf/acl.
+ conf.setBoolean(CommonConfigurationKeys.HADOOP_SECURITY_AUTHORIZATION,
false);
+
+ HttpServer myServer = new HttpServer.Builder().setName("test")
+ .addEndpoint(new
URI("http://localhost:0")).setFindPort(true).setConf(conf)
+ .setACL(new AccessControlList("adminUser adminGroup")).build();
+ myServer.start();
+
+ // Plant a finished profile file in OUTPUT_DIR so the servlet has
something to serve.
+ ProfileServlet.ensureOutputDir();
+ java.io.File profileFile =
+ new java.io.File(ProfileServlet.OUTPUT_DIR,
"test-profile-access-control.html");
+ java.nio.file.Files.write(profileFile.toPath(),
+ "<html>flame</html>".getBytes(java.nio.charset.StandardCharsets.UTF_8),
+ java.nio.file.StandardOpenOption.CREATE,
java.nio.file.StandardOpenOption.TRUNCATE_EXISTING);
+
+ try {
+ String base = "http://" +
NetUtils.getHostPortString(myServer.getConnectorAddress(0)) + "/";
+ String outputPath = "prof-output-hbase/" + profileFile.getName();
+
+ // Must return 200, not 500. A 500 would mean NPE: conf or acl was null
inside
+ // AdminAuthorizedFilter because setContextAttributes() was not called
on genCtx.
+ assertEquals(HttpURLConnection.HTTP_OK, getHttpStatusCode(base +
outputPath, "adminUser"));
+ } finally {
+ profileFile.delete();
+ myServer.stop();
+ }
+ }
}
diff --git
a/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfileServlet.java
b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfileServlet.java
new file mode 100644
index 00000000000..6ff81273047
--- /dev/null
+++
b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfileServlet.java
@@ -0,0 +1,615 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hadoop.hbase.http;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.lang.reflect.Field;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import javax.servlet.ServletConfig;
+import javax.servlet.ServletContext;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hbase.testclassification.MiscTests;
+import org.apache.hadoop.hbase.testclassification.SmallTests;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+@Tag(MiscTests.TAG)
+@Tag(SmallTests.TAG)
+public class TestProfileServlet {
+
+ @BeforeEach
+ public void resetStaticStateBeforeEach() throws Exception {
+ clearLastResult();
+ resetProfiling();
+ }
+
+ @AfterEach
+ public void joinStopperThreadAfterEach() throws Exception {
+ // T1: stopper threads left running by a test can write lastResult after
the test body
+ // returns, overwriting the @BeforeEach clear of the next test. Join any
live stopper so
+ // the next test starts with fully settled static state.
+ for (Thread t : Thread.getAllStackTraces().keySet()) {
+ if ("ProfileServlet-stopper".equals(t.getName()) && t.isAlive()) {
+ t.join(5000);
+ }
+ }
+ }
+
+ // ---- parseProfileRequest ----
+
+ @Test
+ public void testParseProfileRequestDefaults() {
+ ProfileServlet servlet = new ProfileServlet(null);
+ HttpServletRequest req = mockRequest(Collections.emptyMap(), "pid", null,
"duration", null,
+ "output", null, "event", null, "interval", null, "jstackdepth", null,
"bufsize", null,
+ "width", null, "height", null, "minwidth", null, "refreshDelay", null);
+
+ ProfileServlet.ProfileRequest parsed = servlet.parseProfileRequest(req);
+ assertNull(parsed.getPid());
+ assertEquals(10, parsed.getDuration());
+ assertEquals(ProfileServlet.Event.CPU, parsed.getEvent());
+ assertEquals(ProfileServlet.Output.HTML, parsed.getOutput());
+ assertFalse(parsed.isThread());
+ assertFalse(parsed.isSimple());
+ assertFalse(parsed.isReverse());
+ }
+
+ @Test
+ public void testParseProfileRequestAllOptions() {
+ Map<String, String[]> flags = new HashMap<>();
+ flags.put("thread", new String[] { "" });
+ flags.put("simple", new String[] { "" });
+ flags.put("reverse", new String[] { "" });
+
+ ProfileServlet servlet = new ProfileServlet(null);
+ HttpServletRequest req = mockRequest(flags, "pid", "42", "duration", "60",
"output", "tree",
+ "event", "alloc", "interval", "1000", "jstackdepth", "256", "bufsize",
"100000", "width",
+ "1200", "height", "16", "minwidth", "0.5", "refreshDelay", "3");
+
+ ProfileServlet.ProfileRequest parsed = servlet.parseProfileRequest(req);
+ assertEquals(42, parsed.getPid());
+ assertEquals(60, parsed.getDuration());
+ assertEquals(ProfileServlet.Output.TREE, parsed.getOutput());
+ assertEquals(ProfileServlet.Event.ALLOC, parsed.getEvent());
+ assertEquals(1000L, parsed.getInterval());
+ assertEquals(256, parsed.getJstackDepth());
+ assertEquals(100000L, parsed.getBufsize());
+ assertEquals(1200, parsed.getWidth());
+ assertEquals(16, parsed.getHeight());
+ assertEquals(0.5, parsed.getMinwidth(), 1e-9);
+ assertEquals(3, parsed.getRefreshDelay());
+ assertTrue(parsed.isThread());
+ assertTrue(parsed.isSimple());
+ assertTrue(parsed.isReverse());
+ }
+
+ // ---- doGet ----
+
+ @Test
+ public void testDoGetSetsRefreshHeaderAndCallsBackend() throws Exception {
+ ProfilerBackend mockBackend = Mockito.mock(ProfilerBackend.class);
+ Mockito.when(mockBackend.executeStart(Mockito.any(),
Mockito.any())).thenReturn("OK");
+
+ ProfileServlet servlet = new ProfileServlet(mockBackend);
+ servlet.init(mockServletConfig());
+
+ HttpServletRequest req = mockRequest(Collections.emptyMap(), "pid", null,
"duration", "1",
+ "refreshDelay", "2", "output", null, "event", null, "interval", null,
"jstackdepth", null,
+ "bufsize", null, "width", null, "height", null, "minwidth", null);
+
+ HttpServletResponse resp = Mockito.mock(HttpServletResponse.class);
+ StringWriter body = new StringWriter();
+ Mockito.when(resp.getWriter()).thenReturn(new PrintWriter(body));
+
+ servlet.doGet(req, resp);
+
+ Mockito.verify(mockBackend).executeStart(Mockito.any(), Mockito.any());
+ Mockito.verify(resp).setStatus(HttpServletResponse.SC_ACCEPTED);
+
+ ArgumentCaptor<String> refreshCaptor =
ArgumentCaptor.forClass(String.class);
+ Mockito.verify(resp).setHeader(Mockito.eq("Refresh"),
refreshCaptor.capture());
+ assertTrue(refreshCaptor.getValue().startsWith("3;"));
+ assertTrue(refreshCaptor.getValue().contains("/prof-output-hbase/"));
+ }
+
+ // ---- doGet error paths ----
+
+ @Test
+ public void testDoGetBackendThrowsRuntimeException() throws Exception {
+ ProfilerBackend mockBackend = Mockito.mock(ProfilerBackend.class);
+ Mockito.when(mockBackend.executeStart(Mockito.any(), Mockito.any()))
+ .thenThrow(new IllegalStateException("profiler already started"));
+
+ ProfileServlet servlet = new ProfileServlet(mockBackend);
+ servlet.init(mockServletConfig());
+
+ HttpServletRequest req = mockRequest(Collections.emptyMap(), "pid", null,
"duration", "1",
+ "refreshDelay", null, "output", null, "event", null, "interval", null,
"jstackdepth", null,
+ "bufsize", null, "width", null, "height", null, "minwidth", null);
+ HttpServletResponse resp = Mockito.mock(HttpServletResponse.class);
+ StringWriter body = new StringWriter();
+ Mockito.when(resp.getWriter()).thenReturn(new PrintWriter(body));
+
+ servlet.doGet(req, resp);
+
+
Mockito.verify(resp).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ assertTrue(body.toString().contains("Profiler error"));
+ }
+
+ @Test
+ public void testDoGetBackendThrowsError() throws Exception {
+ ProfilerBackend mockBackend = Mockito.mock(ProfilerBackend.class);
+ Mockito.when(mockBackend.executeStart(Mockito.any(), Mockito.any()))
+ .thenThrow(new UnsatisfiedLinkError("no libasyncProfiler in
java.library.path"));
+
+ ProfileServlet servlet = new ProfileServlet(mockBackend);
+ servlet.init(mockServletConfig());
+
+ HttpServletRequest req = mockRequest(Collections.emptyMap(), "pid", null,
"duration", "1",
+ "refreshDelay", null, "output", null, "event", null, "interval", null,
"jstackdepth", null,
+ "bufsize", null, "width", null, "height", null, "minwidth", null);
+ HttpServletResponse resp = Mockito.mock(HttpServletResponse.class);
+ StringWriter body = new StringWriter();
+ Mockito.when(resp.getWriter()).thenReturn(new PrintWriter(body));
+
+ servlet.doGet(req, resp);
+
+
Mockito.verify(resp).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ assertTrue(body.toString().contains("Profiler error"));
+ }
+
+ @Test
+ public void testDoGetRejectsNonPositivePidWith400() throws Exception {
+ ProfilerBackend mockBackend = Mockito.mock(ProfilerBackend.class);
+ ProfileServlet servlet = new ProfileServlet(mockBackend);
+ servlet.init(mockServletConfig());
+
+ for (String badPid : new String[] { "-1", "0", "-999" }) {
+ HttpServletRequest req = mockRequest(Collections.emptyMap(), "pid",
badPid, "duration", "1",
+ "refreshDelay", null, "output", null, "event", null, "interval", null,
"jstackdepth", null,
+ "bufsize", null, "width", null, "height", null, "minwidth", null);
+ HttpServletResponse resp = Mockito.mock(HttpServletResponse.class);
+ StringWriter body = new StringWriter();
+ Mockito.when(resp.getWriter()).thenReturn(new PrintWriter(body));
+
+ servlet.doGet(req, resp);
+
+ Mockito.verify(resp).setStatus(HttpServletResponse.SC_BAD_REQUEST);
+ assertTrue(body.toString().contains("Invalid pid"),
+ "Expected 'Invalid pid' in response for pid=" + badPid + ", got: " +
body);
+ // backend must never be called for an invalid pid
+ Mockito.verify(mockBackend, Mockito.never()).executeStart(Mockito.any(),
Mockito.any());
+
+ // reset mocks for next iteration
+ Mockito.reset(resp, mockBackend);
+ }
+ }
+
+ @Test
+ public void testDoGetSecondRequestRejectedWithConflictWhenProfilingActive()
throws Exception {
+ // T2: without synchronization the stopper's 1s sleep could finish before
the second doGet
+ // runs on a slow CI runner, producing 202 instead of 409. Block
executeStop on a latch so
+ // profiling=true is guaranteed to be set when the second request arrives.
+ CountDownLatch secondRequestIssued = new CountDownLatch(1);
+ ProfilerBackend mockBackend = Mockito.mock(ProfilerBackend.class);
+ Mockito.when(mockBackend.executeStart(Mockito.any(),
Mockito.any())).thenReturn("OK");
+ Mockito.when(mockBackend.executeStop(Mockito.any(),
Mockito.any())).thenAnswer(inv -> {
+ secondRequestIssued.await();
+ return "";
+ });
+
+ ProfileServlet servlet = new ProfileServlet(mockBackend);
+ servlet.init(mockServletConfig());
+
+ HttpServletRequest req = mockRequest(Collections.emptyMap(), "pid", null,
"duration", "1",
+ "refreshDelay", null, "output", null, "event", null, "interval", null,
"jstackdepth", null,
+ "bufsize", null, "width", null, "height", null, "minwidth", null);
+
+ // First request succeeds and sets profiling=true.
+ HttpServletResponse resp1 = Mockito.mock(HttpServletResponse.class);
+ Mockito.when(resp1.getWriter()).thenReturn(new PrintWriter(new
StringWriter()));
+ servlet.doGet(req, resp1);
+ Mockito.verify(resp1).setStatus(HttpServletResponse.SC_ACCEPTED);
+
+ // Second request must see profiling=true under the lock and return 409
CONFLICT.
+ HttpServletResponse resp2 = Mockito.mock(HttpServletResponse.class);
+ StringWriter body2 = new StringWriter();
+ Mockito.when(resp2.getWriter()).thenReturn(new PrintWriter(body2));
+ servlet.doGet(req, resp2);
+ secondRequestIssued.countDown();
+
+ Mockito.verify(resp2).setStatus(HttpServletResponse.SC_CONFLICT);
+ assertTrue(body2.toString().contains("already running"));
+ }
+
+ // ---- doGet error paths — orphan file cleanup ----
+
+ @Test
+ public void testDoGetDeletesOrphanFileWhenExecuteStartThrows() throws
Exception {
+ ProfilerBackend mockBackend = Mockito.mock(ProfilerBackend.class);
+ ArgumentCaptor<File> fileCaptor = ArgumentCaptor.forClass(File.class);
+ Mockito.when(mockBackend.executeStart(Mockito.any(), fileCaptor.capture()))
+ .thenThrow(new IllegalStateException("profiler already started"));
+
+ ProfileServlet servlet = new ProfileServlet(mockBackend);
+ servlet.init(mockServletConfig());
+
+ HttpServletRequest req = mockRequest(Collections.emptyMap(), "pid", null,
"duration", "1",
+ "refreshDelay", null, "output", null, "event", null, "interval", null,
"jstackdepth", null,
+ "bufsize", null, "width", null, "height", null, "minwidth", null);
+ HttpServletResponse resp = Mockito.mock(HttpServletResponse.class);
+ Mockito.when(resp.getWriter()).thenReturn(new PrintWriter(new
StringWriter()));
+
+ servlet.doGet(req, resp);
+
+
Mockito.verify(resp).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ // The placeholder created for this specific request must have been
deleted.
+ File created = fileCaptor.getValue();
+ assertFalse(created.exists(),
+ "Orphan placeholder must be deleted when executeStart fails: " +
created.getName());
+ }
+
+ @Test
+ public void testStopperThreadWritesPaddedErrorOnExecuteStopFailure() throws
Exception {
+ ProfilerBackend mockBackend = Mockito.mock(ProfilerBackend.class);
+ Mockito.when(mockBackend.executeStart(Mockito.any(),
Mockito.any())).thenReturn("OK");
+ Mockito.when(mockBackend.executeStop(Mockito.any(), Mockito.any()))
+ .thenThrow(new IllegalStateException("stop failed"));
+
+ ProfileServlet servlet = new ProfileServlet(mockBackend);
+ servlet.init(mockServletConfig());
+
+ HttpServletRequest req = mockRequest(Collections.emptyMap(), "pid", null,
"duration", "1",
+ "refreshDelay", null, "output", null, "event", null, "interval", null,
"jstackdepth", null,
+ "bufsize", null, "width", null, "height", null, "minwidth", null);
+ HttpServletResponse resp = Mockito.mock(HttpServletResponse.class);
+ ArgumentCaptor<String> refreshCaptor =
ArgumentCaptor.forClass(String.class);
+ Mockito.when(resp.getWriter()).thenReturn(new PrintWriter(new
StringWriter()));
+
+ servlet.doGet(req, resp);
+ Mockito.verify(resp).setHeader(Mockito.eq("Refresh"),
refreshCaptor.capture());
+
+ // Extract the output file path from the Refresh header URL
+ String refreshValue = refreshCaptor.getValue();
+ // Refresh header: "1;/prof-output-hbase/<filename>"
+ String relUrl = refreshValue.substring(refreshValue.indexOf(';') + 1);
+ String fileName = relUrl.substring(relUrl.lastIndexOf('/') + 1);
+ File outputFile = new File(ProfileServlet.OUTPUT_DIR, fileName);
+
+ // Wait for the stopper thread to finish (duration=1s + some buffer)
+ long deadline = System.currentTimeMillis() + 5000;
+ while (
+ outputFile.length() < ProfileServlet.PROF_OUTPUT_MIN_BYTES
+ && System.currentTimeMillis() < deadline
+ ) {
+ Thread.sleep(50);
+ }
+
+ byte[] content = Files.readAllBytes(outputFile.toPath());
+ assertTrue(content.length > ProfileServlet.PROF_OUTPUT_MIN_BYTES,
+ "Stopper must pad error file to > PROF_OUTPUT_MIN_BYTES so
ProfileOutputServlet stops polling");
+ assertTrue(new String(content, StandardCharsets.UTF_8).contains("stop
failed"),
+ "Padded error file must contain the failure message");
+ }
+
+ @Test
+ public void testStopperThreadWritesPaddedErrorOnInterrupt() throws Exception
{
+ ProfilerBackend mockBackend = Mockito.mock(ProfilerBackend.class);
+ Mockito.when(mockBackend.executeStart(Mockito.any(),
Mockito.any())).thenReturn("OK");
+
+ // Use a long duration so the stopper is sleeping when we interrupt it
+ ProfileServlet servlet = new ProfileServlet(mockBackend);
+ servlet.init(mockServletConfig());
+
+ HttpServletRequest req = mockRequest(Collections.emptyMap(), "pid", null,
"duration", "60",
+ "refreshDelay", null, "output", null, "event", null, "interval", null,
"jstackdepth", null,
+ "bufsize", null, "width", null, "height", null, "minwidth", null);
+ HttpServletResponse resp = Mockito.mock(HttpServletResponse.class);
+ ArgumentCaptor<String> refreshCaptor =
ArgumentCaptor.forClass(String.class);
+ Mockito.when(resp.getWriter()).thenReturn(new PrintWriter(new
StringWriter()));
+
+ servlet.doGet(req, resp);
+ Mockito.verify(resp).setHeader(Mockito.eq("Refresh"),
refreshCaptor.capture());
+
+ String relUrl =
refreshCaptor.getValue().substring(refreshCaptor.getValue().indexOf(';') + 1);
+ String fileName = relUrl.substring(relUrl.lastIndexOf('/') + 1);
+ File outputFile = new File(ProfileServlet.OUTPUT_DIR, fileName);
+
+ // Find the stopper thread by name and interrupt it while it is sleeping
+ Thread stopper = null;
+ long findDeadline = System.currentTimeMillis() + 2000;
+ while (stopper == null && System.currentTimeMillis() < findDeadline) {
+ for (Thread t : Thread.getAllStackTraces().keySet()) {
+ if ("ProfileServlet-stopper".equals(t.getName()) && t.isAlive()) {
+ stopper = t;
+ break;
+ }
+ }
+ if (stopper == null) {
+ Thread.sleep(10);
+ }
+ }
+ assertNotNull(stopper, "ProfileServlet-stopper thread must be alive during
the sleep");
+ stopper.interrupt();
+
+ // Wait for the stopper to write the interrupted-session message
+ long deadline = System.currentTimeMillis() + 3000;
+ while (
+ outputFile.length() < ProfileServlet.PROF_OUTPUT_MIN_BYTES
+ && System.currentTimeMillis() < deadline
+ ) {
+ Thread.sleep(50);
+ }
+
+ byte[] content = Files.readAllBytes(outputFile.toPath());
+ assertTrue(content.length > ProfileServlet.PROF_OUTPUT_MIN_BYTES,
+ "Stopper must pad interrupted-session file to > PROF_OUTPUT_MIN_BYTES");
+ assertTrue(new String(content,
StandardCharsets.UTF_8).contains("interrupted"),
+ "Padded error file must contain 'interrupted'");
+ }
+
+ // ---- ?last ----
+
+ @Test
+ public void testLastReturns404WhenNoResultCached() throws Exception {
+ clearLastResult();
+
+ ProfileServlet servlet = new ProfileServlet(null);
+ servlet.init(mockServletConfig());
+ Map<String, String[]> params = new HashMap<>();
+ params.put("last", new String[] { "" });
+ HttpServletRequest req = mockRequest(params, "pid", null, "duration",
null, "output", null,
+ "event", null, "interval", null, "jstackdepth", null, "bufsize", null,
"width", null,
+ "height", null, "minwidth", null, "refreshDelay", null);
+ HttpServletResponse resp = Mockito.mock(HttpServletResponse.class);
+ StringWriter body = new StringWriter();
+ Mockito.when(resp.getWriter()).thenReturn(new PrintWriter(body));
+
+ servlet.doGet(req, resp);
+
+ Mockito.verify(resp).setStatus(HttpServletResponse.SC_NOT_FOUND);
+ assertTrue(body.toString().contains("No profiling results available yet"));
+ }
+
+ @Test
+ public void testLastRedirectsToMostRecentResult() throws Exception {
+ // C8 fix: ?last checks Files.exists before redirecting, so the output
file must exist on disk.
+ String fileName = "profile-cpu-20260612-120000.html";
+ File outputDir = new File(ProfileServlet.OUTPUT_DIR);
+ outputDir.mkdirs();
+ File outputFile = new File(outputDir, fileName);
+ outputFile.createNewFile();
+ String expectedUrl = "/prof-output-hbase/" + fileName;
+ setLastResult(new ProfileServlet.ProfileResult(expectedUrl, "cpu", 10,
Instant.now()));
+
+ ProfileServlet servlet = new ProfileServlet(null);
+ servlet.init(mockServletConfig());
+ Map<String, String[]> params = new HashMap<>();
+ params.put("last", new String[] { "" });
+ HttpServletRequest req = mockRequest(params, "pid", null, "duration",
null, "output", null,
+ "event", null, "interval", null, "jstackdepth", null, "bufsize", null,
"width", null,
+ "height", null, "minwidth", null, "refreshDelay", null);
+ HttpServletResponse resp = Mockito.mock(HttpServletResponse.class);
+ Mockito.when(resp.getWriter()).thenReturn(new PrintWriter(new
StringWriter()));
+
+ servlet.doGet(req, resp);
+
+ Mockito.verify(resp).sendRedirect(expectedUrl);
+ }
+
+ @Test
+ public void testLastResultOverwrittenByNewSession() throws Exception {
+ // C8 fix: ?last checks Files.exists before redirecting, so output files
must exist on disk.
+ File outputDir = new File(ProfileServlet.OUTPUT_DIR);
+ outputDir.mkdirs();
+ File firstFile = new File(outputDir, "profile-cpu-first.html");
+ firstFile.createNewFile();
+ File secondFile = new File(outputDir, "profile-cpu-second.html");
+ secondFile.createNewFile();
+ String firstUrl = "/prof-output-hbase/profile-cpu-first.html";
+ String secondUrl = "/prof-output-hbase/profile-cpu-second.html";
+ setLastResult(new ProfileServlet.ProfileResult(firstUrl, "cpu", 10,
Instant.now()));
+
+ // Overwrite with a second result — only the latest is kept
+ setLastResult(new ProfileServlet.ProfileResult(secondUrl, "cpu", 30,
Instant.now()));
+
+ ProfileServlet servlet = new ProfileServlet(null);
+ servlet.init(mockServletConfig());
+ Map<String, String[]> params = new HashMap<>();
+ params.put("last", new String[] { "" });
+ HttpServletRequest req = mockRequest(params, "pid", null, "duration",
null, "output", null,
+ "event", null, "interval", null, "jstackdepth", null, "bufsize", null,
"width", null,
+ "height", null, "minwidth", null, "refreshDelay", null);
+ HttpServletResponse resp = Mockito.mock(HttpServletResponse.class);
+ Mockito.when(resp.getWriter()).thenReturn(new PrintWriter(new
StringWriter()));
+
+ servlet.doGet(req, resp);
+
+ // Only the most recent result is cached — redirect must point to
secondUrl, not firstUrl
+ Mockito.verify(resp).sendRedirect(secondUrl);
+ Mockito.verify(resp, Mockito.never()).sendRedirect(firstUrl);
+ }
+
+ // ---- parseProfileRequest — enum fallbacks ----
+
+ @Test
+ public void testGetOutputFallsBackToHtmlOnUnknownValue() {
+ ProfileServlet servlet = new ProfileServlet(null);
+ HttpServletRequest req = mockRequest(Collections.emptyMap(), "output",
"bogusformat", "pid",
+ null, "duration", null, "event", null, "interval", null, "jstackdepth",
null, "bufsize", null,
+ "width", null, "height", null, "minwidth", null, "refreshDelay", null);
+ assertEquals(ProfileServlet.Output.HTML,
servlet.parseProfileRequest(req).getOutput());
+ }
+
+ @Test
+ public void testGetEventFallsBackToCpuOnUnknownValue() {
+ ProfileServlet servlet = new ProfileServlet(null);
+ HttpServletRequest req = mockRequest(Collections.emptyMap(), "event",
"bogusevent", "pid", null,
+ "duration", null, "output", null, "interval", null, "jstackdepth", null,
"bufsize", null,
+ "width", null, "height", null, "minwidth", null, "refreshDelay", null);
+ assertEquals(ProfileServlet.Event.CPU,
servlet.parseProfileRequest(req).getEvent());
+ }
+
+ @Test
+ public void testDurationClampedToMinOne() {
+ ProfileServlet servlet = new ProfileServlet(null);
+ HttpServletRequest req = mockRequest(Collections.emptyMap(), "duration",
"0", "pid", null,
+ "output", null, "event", null, "interval", null, "jstackdepth", null,
"bufsize", null,
+ "width", null, "height", null, "minwidth", null, "refreshDelay", null);
+ assertEquals(1, servlet.parseProfileRequest(req).getDuration());
+
+ HttpServletRequest negReq = mockRequest(Collections.emptyMap(),
"duration", "-5", "pid", null,
+ "output", null, "event", null, "interval", null, "jstackdepth", null,
"bufsize", null,
+ "width", null, "height", null, "minwidth", null, "refreshDelay", null);
+ assertEquals(1, servlet.parseProfileRequest(negReq).getDuration());
+ }
+
+ @Test
+ public void testDurationCappedAtMax() {
+ ProfileServlet servlet = new ProfileServlet(null);
+ HttpServletRequest req = mockRequest(Collections.emptyMap(), "duration",
"999999", "pid", null,
+ "output", null, "event", null, "interval", null, "jstackdepth", null,
"bufsize", null,
+ "width", null, "height", null, "minwidth", null, "refreshDelay", null);
+ assertEquals(ProfileServlet.MAX_DURATION_SECONDS,
+ servlet.parseProfileRequest(req).getDuration());
+ }
+
+ // ---- isAvailable / getAsyncProfilerHome ----
+
+ @Test
+ public void testIsAvailableDetectReturnsBackendWhenLibraryPresent() {
+ // async-profiler is on the test classpath (compile-time optional dep
present in tests),
+ // so detect() returns LibraryBackend even with null home.
+ assertNotNull(ProfilerBackend.detect(null));
+ }
+
+ @Test
+ public void testGetAsyncProfilerHomeSystemProperty() {
+ String key = "async.profiler.home";
+ String prev = System.getProperty(key);
+ try {
+ System.setProperty(key, "/tmp/fake-profiler");
+ assertEquals("/tmp/fake-profiler",
ProfileServlet.getAsyncProfilerHome());
+ } finally {
+ if (prev == null) {
+ System.clearProperty(key);
+ } else {
+ System.setProperty(key, prev);
+ }
+ }
+ }
+
+ // ---- DisabledServlet ----
+
+ @Test
+ public void testDisabledServletReturns500WithDefaultReason() throws
Exception {
+ ProfileServlet.DisabledServlet disabled = new
ProfileServlet.DisabledServlet();
+ disabled.init(mockServletConfig());
+ HttpServletRequest req = Mockito.mock(HttpServletRequest.class);
+ HttpServletResponse resp = Mockito.mock(HttpServletResponse.class);
+ StringWriter body = new StringWriter();
+ Mockito.when(resp.getWriter()).thenReturn(new PrintWriter(body));
+
+ disabled.doGet(req, resp);
+
+
Mockito.verify(resp).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ // No reason param set — falls back to the default message
+ assertTrue(body.toString().contains("disabled at startup"));
+ }
+
+ @Test
+ public void testDisabledServletReturns500WithCustomReason() throws Exception
{
+ ProfileServlet.DisabledServlet disabled = new
ProfileServlet.DisabledServlet();
+ ServletConfig config = Mockito.mock(ServletConfig.class);
+ ServletContext ctx = Mockito.mock(ServletContext.class);
+ Mockito.when(config.getServletContext()).thenReturn(ctx);
+
Mockito.when(config.getInitParameter(ProfileServlet.DisabledServlet.REASON_PARAM))
+ .thenReturn("disabled via hbase.profiler.enabled=false");
+ disabled.init(config);
+
+ HttpServletRequest req = Mockito.mock(HttpServletRequest.class);
+ HttpServletResponse resp = Mockito.mock(HttpServletResponse.class);
+ StringWriter body = new StringWriter();
+ Mockito.when(resp.getWriter()).thenReturn(new PrintWriter(body));
+
+ disabled.doGet(req, resp);
+
+
Mockito.verify(resp).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ assertTrue(body.toString().contains("hbase.profiler.enabled=false"));
+ }
+
+ // ---- helpers ----
+
+ private HttpServletRequest mockRequest(Map<String, String[]> paramMap,
String... kvPairs) {
+ HttpServletRequest req = Mockito.mock(HttpServletRequest.class);
+ Mockito.when(req.getParameterMap()).thenReturn(paramMap);
+ for (int i = 0; i < kvPairs.length; i += 2) {
+ Mockito.when(req.getParameter(kvPairs[i])).thenReturn(kvPairs[i + 1]);
+ }
+ return req;
+ }
+
+ private ServletConfig mockServletConfig() throws Exception {
+ ServletContext ctx = Mockito.mock(ServletContext.class);
+ Mockito.when(ctx.getAttribute(HttpServer.CONF_CONTEXT_ATTRIBUTE))
+ .thenReturn(new Configuration(false));
+ ServletConfig config = Mockito.mock(ServletConfig.class);
+ Mockito.when(config.getServletContext()).thenReturn(ctx);
+ return config;
+ }
+
+ private static Field lastResultField() throws Exception {
+ Field f = ProfileServlet.class.getDeclaredField("lastResult");
+ f.setAccessible(true);
+ return f;
+ }
+
+ private static void setLastResult(ProfileServlet.ProfileResult result)
throws Exception {
+ lastResultField().set(null, result);
+ }
+
+ private static void clearLastResult() throws Exception {
+ lastResultField().set(null, null);
+ }
+
+ private static void resetProfiling() throws Exception {
+ Field f = ProfileServlet.class.getDeclaredField("profiling");
+ f.setAccessible(true);
+ f.set(null, false);
+ }
+}
diff --git
a/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfilerBackend.java
b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfilerBackend.java
new file mode 100644
index 00000000000..41dcd539a9d
--- /dev/null
+++
b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfilerBackend.java
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hadoop.hbase.http;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.hadoop.hbase.testclassification.MiscTests;
+import org.apache.hadoop.hbase.testclassification.SmallTests;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+@Tag(MiscTests.TAG)
+@Tag(SmallTests.TAG)
+public class TestProfilerBackend {
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ public void testDetectReturnsLibraryBackendWhenLibraryOnClasspath() {
+ // async-profiler is on the test classpath, so detect() always returns
LibraryBackend
+ // regardless of home setting — library takes priority.
+ ProfilerBackend backend = ProfilerBackend.detect(null);
+ assertNotNull(backend);
+ assertInstanceOf(LibraryBackend.class, backend);
+ }
+
+ @Test
+ public void testDetectReturnsBinaryBackendWhenHomeSet() throws Exception {
+ // Create a fake profiler home with bin/asprof so path check passes
+ Files.createDirectories(tempDir.resolve("bin"));
+ Files.createFile(tempDir.resolve("bin").resolve("asprof"));
+
+ // The test classpath has the async-profiler JAR (optional compile dep),
so detect() returns
+ // LibraryBackend here. BinaryBackend selection is verified under
isolation by
+ //
TestProfilerBackendIsolated.testDetectReturnsBinaryBackendWhenLibraryAbsentButHomeSet.
+ // This test simply asserts that a valid home always yields a non-null
backend.
+ assertNotNull(ProfilerBackend.detect(tempDir.toString()));
+ }
+
+ @Test
+ public void testBinaryBackendDetectReturnsNonNullWhenHomeProvided() {
+ // Any non-empty home string produces a non-null backend (LibraryBackend
when JAR is present,
+ // BinaryBackend when absent). Both are valid — what matters is non-null.
+ assertNotNull(ProfilerBackend.detect("/fake/profiler/home"));
+ }
+
+ @Test
+ public void testDetectPrefersLibraryWhenBothAvailable() {
+ // Library takes priority over binary home. Since the JAR is on the test
classpath,
+ // detect() must return LibraryBackend even when a home is provided.
+ ProfilerBackend backend = ProfilerBackend.detect("/some/home");
+ assertNotNull(backend);
+ assertInstanceOf(LibraryBackend.class, backend);
+ }
+
+ @Test
+ public void testBinaryBackendDestroyDoesNotThrowWhenNoProcess() {
+ BinaryBackend backend = new BinaryBackend("/fake/home");
+ // Should not throw when no process has been started
+ backend.destroy();
+ }
+
+ @Test
+ public void testBinaryBackendExecuteStartFailureIsNotNullPointerException()
throws Exception {
+ // BinaryBackend.executeStart must surface failures as IOException or
RuntimeException,
+ // never NullPointerException. The null-PID guard at
BinaryBackend.java:116-118 cannot be
+ // triggered without mocking ProcessUtils.getPid() (static method), but we
verify the
+ // adjacent failure path: a non-existent profiler home causes
ProcessBuilder.start() to fail,
+ // which runCmdAsync wraps in IllegalStateException. This confirms the
error path goes through
+ // the expected exception type and not an unguarded NPE from
pid.toString().
+ BinaryBackend backend = new
BinaryBackend("/fake/home/that/does/not/exist");
+ ProfileServlet servlet = new ProfileServlet(null);
+ javax.servlet.http.HttpServletRequest req =
+ org.mockito.Mockito.mock(javax.servlet.http.HttpServletRequest.class);
+
org.mockito.Mockito.when(req.getParameterMap()).thenReturn(java.util.Collections.emptyMap());
+
org.mockito.Mockito.when(req.getParameter(org.mockito.Mockito.anyString())).thenReturn(null);
+ ProfileServlet.ProfileRequest profileReq =
servlet.parseProfileRequest(req);
+ Throwable caught = null;
+ try {
+ backend.executeStart(profileReq, File.createTempFile("test", ".html"));
+ } catch (Exception e) {
+ caught = e;
+ }
+ assertNotNull(caught, "executeStart must throw when profiler home is
missing");
+ assertFalse(caught instanceof NullPointerException,
+ "executeStart must not throw NullPointerException; got: " +
caught.getClass().getName());
+ }
+}
diff --git
a/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfilerBackendIsolated.java
b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfilerBackendIsolated.java
new file mode 100644
index 00000000000..3f455c53df9
--- /dev/null
+++
b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfilerBackendIsolated.java
@@ -0,0 +1,163 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hadoop.hbase.http;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.lang.reflect.Method;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.hadoop.hbase.testclassification.MiscTests;
+import org.apache.hadoop.hbase.testclassification.SmallTests;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Verifies {@link ProfilerBackend#detect} fallback behaviour when
+ * {@code one.profiler.AsyncProfiler} is absent from the classpath.
+ * <p>
+ * Each test loads {@code ProfilerBackend} through a custom {@link
ClassLoader} that blocks
+ * {@code one.profiler.*}, simulating a deployment where the async-profiler
JAR was never packaged.
+ * This is the exact scenario for users who have async-profiler installed as a
native binary
+ * ({@code ASYNC_PROFILER_HOME}) but are not allowed to bundle the JAR.
+ * <p>
+ * The split of {@link LibraryBackend} into its own file is what makes this
possible:
+ * {@code ProfilerBackend.class} carries no static reference to {@code
AsyncProfiler}, so the
+ * isolated loader can load it without a {@code NoClassDefFoundError}.
+ */
+@Tag(MiscTests.TAG)
+@Tag(SmallTests.TAG)
+public class TestProfilerBackendIsolated {
+
+ @TempDir
+ Path tempDir;
+
+ /**
+ * When the library is absent AND no home is set, detect() must return null
so that HttpServer
+ * registers DisabledServlet instead of crashing.
+ */
+ @Test
+ public void testDetectReturnsNullWhenLibraryAbsentAndNoHome() throws
Exception {
+ ClassLoader isolated = isolatedLoader();
+ Method detect = detectMethod(isolated);
+
+ assertNull(detect.invoke(null, (String) null));
+ assertNull(detect.invoke(null, ""));
+ assertNull(detect.invoke(null, " "));
+ }
+
+ /**
+ * User has async-profiler installed as a native binary (ASYNC_PROFILER_HOME
set, bin/asprof
+ * present) but no JAR on the classpath. detect() must return BinaryBackend.
+ */
+ @Test
+ public void testDetectReturnsBinaryBackendWhenLibraryAbsentButHomeSet()
throws Exception {
+ // Create a minimal fake profiler home with bin/asprof
+ Files.createDirectories(tempDir.resolve("bin"));
+ Files.createFile(tempDir.resolve("bin").resolve("asprof"));
+
+ ClassLoader isolated = isolatedLoader();
+ Method detect = detectMethod(isolated);
+
+ Object backend = detect.invoke(null, tempDir.toString());
+ assertNotNull(backend);
+ assertEquals("BinaryBackend", backend.getClass().getSimpleName());
+ }
+
+ /**
+ * When the library IS on the classpath (normal test classpath), detect()
must return
+ * LibraryBackend regardless of whether a home is set — library takes
priority.
+ */
+ @Test
+ public void testDetectReturnsLibraryBackendWhenLibraryPresent() {
+ // Use real classpath — async-profiler JAR is present as optional compile
dep in tests
+ ProfilerBackend backend = ProfilerBackend.detect(null);
+ assertNotNull(backend);
+ assertEquals("LibraryBackend", backend.getClass().getSimpleName());
+ }
+
+ /**
+ * Library present AND home set — LibraryBackend must still win (priority
check).
+ */
+ @Test
+ public void testDetectPrefersLibraryWhenBothPresent() throws Exception {
+ Files.createDirectories(tempDir.resolve("bin"));
+ Files.createFile(tempDir.resolve("bin").resolve("asprof"));
+
+ ProfilerBackend backend = ProfilerBackend.detect(tempDir.toString());
+ assertNotNull(backend);
+ assertEquals("LibraryBackend", backend.getClass().getSimpleName());
+ }
+
+ // ---- helpers ----
+
+ /**
+ * Returns a ClassLoader that: - blocks {@code one.profiler.*} entirely
(simulates absent
+ * async-profiler JAR) - reloads {@code org.apache.hadoop.hbase.http.*}
classes fresh (so
+ * LibraryBackend resolves its own imports through this loader and also sees
one.profiler.* as
+ * absent) - delegates everything else to the parent
+ */
+ private ClassLoader isolatedLoader() {
+ ClassLoader parent = getClass().getClassLoader();
+ return new ClassLoader(parent) {
+ @Override
+ protected Class<?> loadClass(String name, boolean resolve) throws
ClassNotFoundException {
+ if (name.startsWith("one.profiler.")) {
+ throw new ClassNotFoundException("Simulated absent library: " +
name);
+ }
+ // Force fresh load of our http package so LibraryBackend uses this
loader
+ // (and therefore also sees one.profiler.* as absent when it tries to
resolve it)
+ if (name.startsWith("org.apache.hadoop.hbase.http.")) {
+ Class<?> c = findLoadedClass(name);
+ if (c != null) {
+ return c;
+ }
+ // Load bytes from parent, define in this loader
+ String path = name.replace('.', '/') + ".class";
+ try (java.io.InputStream in = parent.getResourceAsStream(path)) {
+ if (in != null) {
+ byte[] bytes = in.readAllBytes();
+ c = defineClass(name, bytes, 0, bytes.length);
+ if (resolve) {
+ resolveClass(c);
+ }
+ return c;
+ }
+ } catch (java.io.IOException e) {
+ throw new ClassNotFoundException(name, e);
+ }
+ }
+ return super.loadClass(name, resolve);
+ }
+ };
+ }
+
+ /**
+ * Loads {@code ProfilerBackend} through the given loader and returns its
{@code detect(String)}
+ * method, made accessible across loader boundaries.
+ */
+ private Method detectMethod(ClassLoader loader) throws Exception {
+ Class<?> backendClass =
loader.loadClass("org.apache.hadoop.hbase.http.ProfilerBackend");
+ Method m = backendClass.getMethod("detect", String.class);
+ m.setAccessible(true);
+ return m;
+ }
+}
diff --git
a/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfilerCommandMapper.java
b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfilerCommandMapper.java
new file mode 100644
index 00000000000..08cbc0a807c
--- /dev/null
+++
b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfilerCommandMapper.java
@@ -0,0 +1,265 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hadoop.hbase.http;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.servlet.http.HttpServletRequest;
+import org.apache.hadoop.hbase.testclassification.MiscTests;
+import org.apache.hadoop.hbase.testclassification.SmallTests;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mockito;
+
+@Tag(MiscTests.TAG)
+@Tag(SmallTests.TAG)
+public class TestProfilerCommandMapper {
+
+ @TempDir
+ Path tempDir;
+
+ // ---- Library start command ----
+
+ @Test
+ public void testLibraryStartCommandDefaults() {
+ ProfileServlet.ProfileRequest req = parseRequest(Collections.emptyMap());
+ String cmd = ProfilerCommandMapper.toLibraryStartCommand(req);
+ assertTrue(cmd.startsWith("start"));
+ assertTrue(cmd.contains("event=cpu"));
+ assertFalse(cmd.contains("interval"));
+ assertFalse(cmd.contains("threads"));
+ assertFalse(cmd.contains("simple"));
+ }
+
+ @Test
+ public void testLibraryStartCommandAllOptions() {
+ Map<String, String[]> flags = new HashMap<>();
+ flags.put("thread", new String[] { "" });
+ flags.put("simple", new String[] { "" });
+
+ ProfileServlet.ProfileRequest req = parseRequest(flags, "event", "alloc",
"interval", "1000",
+ "jstackdepth", "256", "bufsize", "100000");
+ String cmd = ProfilerCommandMapper.toLibraryStartCommand(req);
+ assertTrue(cmd.contains("event=alloc"));
+ assertTrue(cmd.contains("interval=1000"));
+ assertTrue(cmd.contains("jstackdepth=256"));
+ // bufsize= is not a recognized 4.x agent option and must not be emitted
for LibraryBackend
+ assertFalse(cmd.contains("bufsize"));
+ assertTrue(cmd.contains("threads"));
+ assertTrue(cmd.contains("simple"));
+ }
+
+ // ---- Library stop command ----
+
+ @Test
+ public void testLibraryStopCommandHtml() throws IOException {
+ Map<String, String[]> flags = new HashMap<>();
+ flags.put("reverse", new String[] { "" });
+ ProfileServlet.ProfileRequest req =
+ parseRequest(flags, "output", "html", "width", "1200", "height", "16",
"minwidth", "0.5");
+
+ File outputFile = File.createTempFile("prof", ".html");
+ outputFile.deleteOnExit();
+
+ String cmd = ProfilerCommandMapper.toLibraryStopCommand(req, outputFile);
+ assertTrue(cmd.startsWith("stop"));
+ assertTrue(cmd.contains("file=" + outputFile.getAbsolutePath()));
+ // html: format derived from .html extension — no format= key emitted
+ assertFalse(cmd.contains("format="));
+ // width/height not recognized by 4.x agent — must not be emitted
+ // (use ",width=" prefix to avoid matching ",minwidth=")
+ assertFalse(cmd.contains(",width="));
+ assertFalse(cmd.contains(",height="));
+ assertTrue(cmd.contains("minwidth=0.5"));
+ assertTrue(cmd.contains("reverse"));
+ }
+
+ @Test
+ public void testLibraryStopCommandTree() throws IOException {
+ ProfileServlet.ProfileRequest req = parseRequest(Collections.emptyMap(),
"output", "tree");
+ File outputFile = File.createTempFile("prof", ".tree");
+ outputFile.deleteOnExit();
+
+ String cmd = ProfilerCommandMapper.toLibraryStopCommand(req, outputFile);
+ // text-based formats need the bare token
+ assertTrue(cmd.contains(",tree"));
+ assertFalse(cmd.contains("format="));
+ }
+
+ @Test
+ public void testLibraryStopCommandCollapsed() throws IOException {
+ ProfileServlet.ProfileRequest req = parseRequest(Collections.emptyMap(),
"output", "collapsed");
+ File outputFile = File.createTempFile("prof", ".collapsed");
+ outputFile.deleteOnExit();
+
+ String cmd = ProfilerCommandMapper.toLibraryStopCommand(req, outputFile);
+ // collapsed must be passed as a bare token — .collapsed extension is not
auto-detected by 4.x
+ assertTrue(cmd.contains(",collapsed"));
+ assertFalse(cmd.contains("format="));
+ }
+
+ @Test
+ public void testLibraryStopCommandSvgRemappedToHtml() throws IOException {
+ ProfileServlet.ProfileRequest req = parseRequest(Collections.emptyMap(),
"output", "svg");
+ File outputFile = File.createTempFile("prof", ".html");
+ outputFile.deleteOnExit();
+
+ String cmd = ProfilerCommandMapper.toLibraryStopCommand(req, outputFile);
+ // SVG is obsolete — must be remapped to html
+ assertFalse(cmd.contains("svg"));
+ assertFalse(cmd.contains("format="));
+ }
+
+ // ---- CLI command ----
+
+ @Test
+ public void testCliCommandDefaultScript() throws IOException {
+ // Create bin/asprof so the primary script path exists
+ Path binDir = Files.createDirectories(tempDir.resolve("bin"));
+ Files.createFile(binDir.resolve("asprof"));
+
+ ProfileServlet.ProfileRequest req =
+ parseRequest(Collections.emptyMap(), "duration", "30", "output", "html");
+ File outputFile = File.createTempFile("prof", ".html");
+ outputFile.deleteOnExit();
+
+ List<String> cmd =
+ ProfilerCommandMapper.toCliCommand(req, outputFile, tempDir.toString(),
1234);
+ assertEquals(tempDir.resolve("bin/asprof").toString(), cmd.get(0));
+ assertTrue(cmd.contains("-e"));
+ assertTrue(cmd.contains("cpu"));
+ assertTrue(cmd.contains("-d"));
+ assertTrue(cmd.contains("30"));
+ assertTrue(cmd.contains("-o"));
+ assertTrue(cmd.contains("html"));
+ assertTrue(cmd.contains("-f"));
+ assertTrue(cmd.contains(outputFile.getAbsolutePath()));
+ assertTrue(cmd.contains("1234"));
+ }
+
+ @Test
+ public void testCliCommandSvgRemappedToHtml() throws IOException {
+ Path binDir = Files.createDirectories(tempDir.resolve("bin"));
+ Files.createFile(binDir.resolve("asprof"));
+
+ ProfileServlet.ProfileRequest req = parseRequest(Collections.emptyMap(),
"output", "svg");
+ File outputFile = File.createTempFile("prof", ".html");
+ outputFile.deleteOnExit();
+
+ List<String> cmd =
+ ProfilerCommandMapper.toCliCommand(req, outputFile, tempDir.toString(),
1234);
+ // SVG is obsolete; toCliCommand must remap to html, not pass "-o svg"
+ assertFalse(cmd.contains("svg"));
+ assertTrue(cmd.contains("html"));
+ }
+
+ @Test
+ public void testCliCommandFallbackToOldScript() throws IOException {
+ // Do NOT create bin/asprof — only create profiler.sh as fallback
+ Files.createFile(tempDir.resolve("profiler.sh"));
+
+ ProfileServlet.ProfileRequest req = parseRequest(Collections.emptyMap());
+ File outputFile = File.createTempFile("prof", ".html");
+ outputFile.deleteOnExit();
+
+ List<String> cmd =
+ ProfilerCommandMapper.toCliCommand(req, outputFile, tempDir.toString(),
1234);
+ assertEquals(tempDir.resolve("profiler.sh").toString(), cmd.get(0));
+ }
+
+ @Test
+ public void testCliCommandAllOptions() throws IOException {
+ Path binDir = Files.createDirectories(tempDir.resolve("bin"));
+ Files.createFile(binDir.resolve("asprof"));
+
+ Map<String, String[]> flags = new HashMap<>();
+ flags.put("thread", new String[] { "" });
+ flags.put("simple", new String[] { "" });
+ flags.put("reverse", new String[] { "" });
+
+ ProfileServlet.ProfileRequest req = parseRequest(flags, "event", "alloc",
"interval", "500",
+ "jstackdepth", "128", "bufsize", "50000", "width", "800", "height",
"12", "minwidth", "1.0");
+ File outputFile = File.createTempFile("prof", ".html");
+ outputFile.deleteOnExit();
+
+ List<String> cmd = ProfilerCommandMapper.toCliCommand(req, outputFile,
tempDir.toString(), 99);
+ assertTrue(cmd.contains("-e"));
+ assertTrue(cmd.contains("alloc"));
+ assertTrue(cmd.contains("-i"));
+ assertTrue(cmd.contains("500"));
+ assertTrue(cmd.contains("-j"));
+ assertTrue(cmd.contains("128"));
+ assertTrue(cmd.contains("-b"));
+ assertTrue(cmd.contains("50000"));
+ assertTrue(cmd.contains("-t"));
+ assertTrue(cmd.contains("-s"));
+ assertTrue(cmd.contains("--width"));
+ assertTrue(cmd.contains("800"));
+ assertTrue(cmd.contains("--height"));
+ assertTrue(cmd.contains("12"));
+ assertTrue(cmd.contains("--minwidth"));
+ assertTrue(cmd.contains("1.0"));
+ assertTrue(cmd.contains("--reverse"));
+ }
+
+ // ---- Format mapping ----
+
+ @Test
+ public void testOutputFormatMappingAllValues() {
+ assertEquals("summary",
ProfilerCommandMapper.toFormatString(ProfileServlet.Output.SUMMARY));
+ assertEquals("traces",
ProfilerCommandMapper.toFormatString(ProfileServlet.Output.TRACES));
+ assertEquals("flat",
ProfilerCommandMapper.toFormatString(ProfileServlet.Output.FLAT));
+ assertEquals("collapsed",
+ ProfilerCommandMapper.toFormatString(ProfileServlet.Output.COLLAPSED));
+ assertEquals("tree",
ProfilerCommandMapper.toFormatString(ProfileServlet.Output.TREE));
+ assertEquals("jfr",
ProfilerCommandMapper.toFormatString(ProfileServlet.Output.JFR));
+ // SVG is obsolete in async-profiler 2.x+ — remapped to html
+ assertEquals("html",
ProfilerCommandMapper.toFormatString(ProfileServlet.Output.SVG));
+ assertEquals("html",
ProfilerCommandMapper.toFormatString(ProfileServlet.Output.HTML));
+ }
+
+ // ---- helpers ----
+
+ private ProfileServlet.ProfileRequest parseRequest(Map<String, String[]>
paramMap,
+ String... kvPairs) {
+ ProfileServlet servlet = new ProfileServlet(null);
+ HttpServletRequest req = Mockito.mock(HttpServletRequest.class);
+ Mockito.when(req.getParameterMap()).thenReturn(paramMap);
+ // defaults
+ String[] keys = { "pid", "duration", "output", "event", "interval",
"jstackdepth", "bufsize",
+ "width", "height", "minwidth", "refreshDelay" };
+ for (String k : keys) {
+ Mockito.when(req.getParameter(k)).thenReturn(null);
+ }
+ for (int i = 0; i < kvPairs.length; i += 2) {
+ Mockito.when(req.getParameter(kvPairs[i])).thenReturn(kvPairs[i + 1]);
+ }
+ return servlet.parseProfileRequest(req);
+ }
+}
diff --git a/hbase-resource-bundle/src/main/resources/supplemental-models.xml
b/hbase-resource-bundle/src/main/resources/supplemental-models.xml
index c2083c8ebae..6d6963890e3 100644
--- a/hbase-resource-bundle/src/main/resources/supplemental-models.xml
+++ b/hbase-resource-bundle/src/main/resources/supplemental-models.xml
@@ -55,6 +55,20 @@ under the License.
</project>
</supplement>
+ <supplement>
+ <project>
+ <groupId>tools.profiler</groupId>
+ <artifactId>async-profiler</artifactId>
+ <licenses>
+ <license>
+ <name>Apache License, Version 2.0</name>
+ <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
+ <distribution>repo</distribution>
+ </license>
+ </licenses>
+ </project>
+ </supplement>
+
<!-- Artifacts with ambiguously named licenses in POM -->
<supplement>
<project>
diff --git a/pom.xml b/pom.xml
index 9b77305d0e6..6c011d0ff82 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1027,6 +1027,7 @@
<vega.version>5.32.0</vega.version>
<vega-embed.version>6.29.0</vega-embed.version>
<vega-lite.version>5.23.0</vega-lite.version>
+ <async-profiler.version>4.4</async-profiler.version>
</properties>
<!-- Sorted by groups of dependencies then groupId and artifactId -->
<dependencyManagement>