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&amp;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&amp;output=tree&amp;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>

Reply via email to