Repository: btraceio/btrace (2026-07-18T09:02:06Z)

Issue:
[3.0] Recursion is caught by neither verifier layer — violates bounded-execution guarantee
## Summary

Recursion (self- and mutual) in a BTrace script handler is caught by **neither** the AST-level `VerifierVisitor` nor the agent-side bytecode `MethodVerifier`. This lets a script violate the documented "bounded / no loops" guarantee: a recursive helper on a hot probe burns deep stack/CPU on every hit until a (caught) `StackOverflowError`.

Surfaced during the pre-release compiler review (with defense-in-depth cross-check against the agent-side verifier).

## Ground truth (verified)

- `VerifierVisitor.java:157` allows same-class method calls.
- `MethodVerifier.java:219` allows `INVOKESTATIC` where `owner == className`.

Neither layer detects a call cycle, so recursion passes both.

Mitigation that keeps this MAJOR rather than BLOCKER: the recursion is bounded by the thread stack and the resulting `StackOverflowError` is caught, so the target application degrades (CPU/stack pressure on the traced thread) rather than crashing.

## Fix options

- Detect direct and mutual recursion via a call-graph cycle check in the verifier (the instrumentation engine already builds a `CallGraph`), or
- If full detection is out of scope for 3.0, at minimum correct the docs (see below) and file a follow-up.

## Related doc-accuracy note (already fixed in the review pass)

The tutorial claimed BTrace scripts "cannot hang" and are "no allocation"; both were softened in `docs/BTraceTutorial.md`, because the verifier also deliberately permits autoboxing and `StringBuilder` string concatenation (`MethodVerifier.java:190,210,221,264`). `docs/QuickReference.md` was already accurate.

Severity: **MAJOR**. Owner: compiler team.



Discussion:

Patch:
diff --git a/btrace-agent/src/main/java/io/btrace/instr/BTraceMethodNode.java b/btrace-agent/src/main/java/io/btrace/instr/BTraceMethodNode.java
index 6643cb207..9046224d0 100644
--- a/btrace-agent/src/main/java/io/btrace/instr/BTraceMethodNode.java
+++ b/btrace-agent/src/main/java/io/btrace/instr/BTraceMethodNode.java
@@ -260,7 +260,7 @@ public AnnotationVisitor visitParameterAnnotation(int parameter, String desc, bo
   @Override
   public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
     owner = cn.translateOwner(owner);
-    if (opcode == Opcodes.INVOKESTATIC) {
+    if (opcode == Opcodes.INVOKESTATIC && owner.equals(cn.getClassName(true))) {
       graph.addEdge(methodId, CallGraph.methodId(name, desc));
     }
     super.visitMethodInsn(opcode, owner, name, desc, itf);
diff --git a/btrace-agent/src/main/java/io/btrace/instr/BTraceProbePersisted.java b/btrace-agent/src/main/java/io/btrace/instr/BTraceProbePersisted.java
index dcf8e99c1..d414890b9 100644
--- a/btrace-agent/src/main/java/io/btrace/instr/BTraceProbePersisted.java
+++ b/btrace-agent/src/main/java/io/btrace/instr/BTraceProbePersisted.java
@@ -63,7 +63,7 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
-import java.util.concurrent.atomic.AtomicBoolean;
+import org.objectweb.asm.AnnotationVisitor;
 import org.objectweb.asm.ClassReader;
 import org.objectweb.asm.ClassVisitor;
 import org.objectweb.asm.FieldVisitor;
@@ -82,13 +82,13 @@ public class BTraceProbePersisted implements BTraceProbe {
   final BTraceProbeSupport delegate;
   private final BTraceProbeFactory factory;
   private final DebugSupport debug;
-  private final AtomicBoolean triedVerify = new AtomicBoolean(false);
+  private volatile VerifierException verificationFailure;
   private final Map<String, Set<String>> calleeMap = new HashMap<>();
   private volatile BTraceRuntime.Impl rt = null;
   private BTraceTransformer transformer;
   private byte[] fullData = null;
   private byte[] dataHolder = null;
-  private boolean preverified;
+  private volatile boolean preverified;
 
   BTraceProbePersisted(BTraceProbeFactory f) {
     this(f, null);
@@ -522,18 +522,29 @@ public boolean isVerified() {
     if (factory.getSettings().isTrusted()) {
       return true;
     }
-    if (triedVerify.compareAndSet(false, true)) {
+    if (preverified) {
+      return true;
+    }
+    synchronized (this) {
+      if (preverified) {
+        return true;
+      }
+      if (verificationFailure != null) {
+        return false;
+      }
       try {
         verifyBytecode();
+        preverified = true;
         return true;
       } catch (VerifierException e) {
+        verificationFailure = e;
         if (Boolean.getBoolean("btrace.verifier.dump")) {
           System.err.println("[BTRACE VERIFY] " + e.getMessage());
         }
         log.debug("Class '{}' verification failed", getClassName(), e);
+        return false;
       }
     }
-    return false;
   }
 
   @Override
@@ -608,8 +619,8 @@ public boolean willInstrument(Class<?> clz) {
 
   @Override
   public void checkVerified() {
-    if (!preverified) {
-      isVerified();
+    if (!isVerified()) {
+      throw verificationFailure;
     }
   }
 
@@ -694,6 +705,7 @@ private void verifyBytecode() throws VerifierException {
     cr.accept(
         new ClassVisitor(ASM9) {
           private String className;
+          private final CallGraph graph = new CallGraph();
 
           @Override
           public void visit(
@@ -765,6 +777,16 @@ public MethodVisitor visitMethod(
             return new MethodVisitor(
                 ASM9, super.visitMethod(access, methodName, desc, sig, exceptions)) {
               private final Map<Label, Label> labels = new HashMap<>();
+              private final String methodId = CallGraph.methodId(methodName, desc);
+              private boolean handler;
+
+              @Override
+              public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
+                if (descriptor.startsWith("Lio/btrace/core/annotations/")) {
+                  handler = true;
+                }
+                return super.visitAnnotation(descriptor, visible);
+              }
 
               @Override
               public void visitFieldInsn(int opcode, String owner, String name, String desc) {
@@ -823,6 +845,9 @@ public void visitJumpInsn(int opcode, Label label) {
               @Override
               public void visitMethodInsn(
                   int opcode, String owner, String name, String desc, boolean itfc) {
+                if (opcode == INVOKESTATIC && owner.equals(className)) {
+                  graph.addEdge(methodId, CallGraph.methodId(name, desc));
+                }
                 switch (opcode) {
                   case INVOKEVIRTUAL:
                     if (MethodVerifier.isPrimitiveWrapper(owner)
@@ -907,8 +932,24 @@ public void visitVarInsn(int opcode, int var) {
                 }
                 super.visitVarInsn(opcode, var);
               }
+
+              @Override
+              public void visitEnd() {
+                if (handler) {
+                  graph.addStarting(methodId);
+                }
+                super.visitEnd();
+              }
             };
           }
+
+          @Override
+          public void visitEnd() {
+            if (graph.hasCycle()) {
+              Verifier.reportError("execution.loop.danger");
+            }
+            super.visitEnd();
+          }
         },
         ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
   }
diff --git a/btrace-agent/src/main/java/io/btrace/instr/CallGraph.java b/btrace-agent/src/main/java/io/btrace/instr/CallGraph.java
index a552cdcc5..562dcb933 100644
--- a/btrace-agent/src/main/java/io/btrace/instr/CallGraph.java
+++ b/btrace-agent/src/main/java/io/btrace/instr/CallGraph.java
@@ -127,7 +127,7 @@ private void collectIncomings(String methodId, Set<String> closure) {
   }
 
   private Set<Node> findCycles() {
-    if (nodes.size() < 2) return Collections.emptySet();
+    if (nodes.isEmpty()) return Collections.emptySet();
 
     Map<String, Node> checkingNodes = new HashMap<>();
     for (Node n : nodes.values()) {
diff --git a/btrace-compiler/src/main/java/io/btrace/compiler/VerifierVisitor.java b/btrace-compiler/src/main/java/io/btrace/compiler/VerifierVisitor.java
index fa72e4b0d..c8da5922a 100644
--- a/btrace-compiler/src/main/java/io/btrace/compiler/VerifierVisitor.java
+++ b/btrace-compiler/src/main/java/io/btrace/compiler/VerifierVisitor.java
@@ -55,9 +55,12 @@
 import java.net.URL;
 import java.net.URLDecoder;
 import java.nio.charset.StandardCharsets;
+import java.util.Collections;
 import java.util.EnumSet;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Properties;
 import java.util.Set;
 import java.util.jar.Attributes;
@@ -87,6 +90,11 @@ public class VerifierVisitor extends TreeScanner<Void, Void> {
   private String className;
   private String fqn;
   private boolean insideMethod;
+  private TypeElement btraceClass;
+  private ExecutableElement currentMethod;
+
+  private final Map<ExecutableElement, Set<ExecutableElement>> callGraph = new HashMap<>();
+  private final Set<ExecutableElement> handlerMethods = new HashSet<>();
 
   private boolean shortSyntax = false;
   // Legacy service type mirrors removed. Only Extension-based checks remain.
@@ -155,6 +163,11 @@ public Void visitMethodInvocation(MethodInvocationTree node, Void v) {
         String typeName = tm.toString();
 
         if (isSameClass(typeName)) {
+          if (currentMethod != null && btraceClass != null && parent.equals(btraceClass)) {
+            callGraph
+                .computeIfAbsent(currentMethod, key -> new HashSet<>())
+                .add((ExecutableElement) e);
+          }
           return super.visitMethodInvocation(node, v);
         }
         if (isBTraceClass(typeName)) {
@@ -299,12 +312,20 @@ public Void visitClass(ClassTree node, Void v) {
         String oldClassName = className;
         try {
           className = node.getSimpleName().toString();
-          fqn = getElement(node).asType().toString();
+          btraceClass = (TypeElement) getElement(node);
+          fqn = btraceClass.asType().toString();
+          callGraph.clear();
+          handlerMethods.clear();
           Void result = super.visitClass(node, v);
+          if (hasHandlerReachableCycle()) {
+            reportError("execution.loop.danger", node);
+          }
           // Permissions are enforced against agent grants at runtime.
           return result;
         } finally {
           className = oldClassName;
+          btraceClass = null;
+          currentMethod = null;
         }
       }
     }
@@ -333,12 +354,18 @@ public Void visitForLoop(ForLoopTree node, Void v) {
   @Override
   public Void visitMethod(MethodTree node, Void v) {
     boolean oldInsideMethod = insideMethod;
+    ExecutableElement oldCurrentMethod = currentMethod;
     insideMethod = true;
     try {
       Name name = node.getName();
       if (name.contentEquals("<init>")) {
         return super.visitMethod(node, v);
       } else {
+        currentMethod = (ExecutableElement) getElement(node);
+        callGraph.computeIfAbsent(currentMethod, key -> new HashSet<>());
+        if (isAnnotated(node)) {
+          handlerMethods.add(currentMethod);
+        }
         checkSampling(node);
 
         if (isExitHandler(node)) {
@@ -404,7 +431,36 @@ public Void visitAnnotation(AnnotationTree at, Void p) {
       }
     } finally {
       insideMethod = oldInsideMethod;
+      currentMethod = oldCurrentMethod;
+    }
+  }
+
+  private boolean hasHandlerReachableCycle() {
+    for (ExecutableElement handler : handlerMethods) {
+      if (hasCycle(handler, new HashSet<>(), new HashSet<>())) {
+        return true;
+      }
     }
+    return false;
+  }
+
+  private boolean hasCycle(
+      ExecutableElement method, Set<ExecutableElement> visiting, Set<ExecutableElement> visited) {
+    if (!visiting.add(method)) {
+      return true;
+    }
+    if (visited.contains(method)) {
+      visiting.remove(method);
+      return false;
+    }
+    for (ExecutableElement callee : callGraph.getOrDefault(method, Collections.emptySet())) {
+      if (hasCycle(callee, visiting, visited)) {
+        return true;
+      }
+    }
+    visiting.remove(method);
+    visited.add(method);
+    return false;
   }
 
   private void addEventFieldNames(AnnotationTree at) {
diff --git a/internal/plans/2026-07-18-issue-890-recursion-verification.md b/internal/plans/2026-07-18-issue-890-recursion-verification.md
new file mode 100644
index 000000000..d3266b034
--- /dev/null
+++ b/internal/plans/2026-07-18-issue-890-recursion-verification.md
@@ -0,0 +1,128 @@
+# Issue #890: recursion verification implementation plan
+
+## Prerequisites and boundaries
+
+- Implement only the contract in
+  `internal/specs/2026-07-18-issue-890-recursion-verification.md`; preserve unrelated changes.
+- Enforce handler-reachable, same-script static direct/mutual cycles for non-trusted probes. Keep
+  acyclic helpers and current trusted/unsafe bypass behavior compatible.
+- Reuse the established `execution.loop.danger` diagnostic and do not expand the work into
+  inter-class dispatch, reflection, invokedynamic, runtime guards, protocol, or documentation.
+
+## Ordered implementation and gates
+
+1. Add source-level graph collection and rejection.
+
+   - Update `btrace-compiler/src/main/java/io/btrace/compiler/VerifierVisitor.java` to collect a
+     per-class graph while visiting attributed method invocations. Use resolved
+     `ExecutableElement` identity for caller/callee (including overload descriptors), record only
+     calls whose callee is declared by the active BTrace class, and mark BTrace handler methods as
+     roots.
+   - After all members of that BTrace class have been scanned, detect direct self-recursion and
+     handler-reachable mutual cycles. Report through the existing source error path with
+     `execution.loop.danger`; preserve existing same-class acyclic-call allowance.
+   - Extend `btrace-compiler/src/test/java/io/btrace/compiler/BTraceDslVerifierTest.java` with
+     direct recursion and two-method mutual-recursion source strings that fail, plus acyclic and
+     overload-distinct controls that compile successfully.
+
+   **Gate:** compilation rejects both recursive forms with the established message before any
+   bytecode is submitted, without rejecting valid helpers or external/BTrace utility calls.
+
+2. Correct the agent graph used by node-format bytecode verification.
+
+   - In `btrace-agent/src/main/java/io/btrace/instr/BTraceMethodNode.java`, add graph edges only
+     for same-probe-class `INVOKESTATIC` instructions after owner translation; retain method name
+     plus descriptor IDs and existing handler-start roots.
+   - In `btrace-agent/src/main/java/io/btrace/instr/CallGraph.java`, recognize a one-node self-edge
+     as a cycle rather than returning false solely because the graph has fewer than two nodes.
+     Preserve the existing handler-reachability logic for mutual cycles and non-reachable helper
+     graphs.
+   - Keep the class-level decision in `btrace-agent/src/main/java/io/btrace/instr/Verifier.java`
+     after all methods have been visited; do not attempt immediate per-instruction rejection.
+   - Extend `btrace-agent/src/test/java/io/btrace/instr/CallGraphTest.java` with a direct self-loop
+     regression and `btrace-agent/src/test/java/io/btrace/instr/MethodVerifierDslTest.java` (or a
+     focused sibling) with handcrafted non-trusted direct/mutual recursive probe bytecode,
+     same-named external-call false-positive coverage, and an acyclic helper control.
+
+   **Gate:** `BTraceProbeFactory.createProbe(...).checkVerified()` throws `VerifierException`
+   containing `execution.loop.danger` for direct and mutual crafted bytecode, but passes for
+   acyclic local helpers and same-named external calls.
+
+3. Close the persisted-probe bypass.
+
+   - Update `btrace-agent/src/main/java/io/btrace/instr/BTraceProbePersisted.java` so persisted
+     bytecode receives an equivalent same-class, handler-reachable cycle check.
+   - Preserve `isVerified()`'s boolean query role if required by callers, but retain/cache the
+     original `VerifierException` it catches (or deterministically rerun verification) so
+     `checkVerified()` can throw that failure when verification returns false. It must not discard
+     a false result or replace the established recursion diagnostic with an unrelated generic
+     failure.
+   - Add a focused persisted-format regression test, preferably
+     `btrace-agent/src/test/java/io/btrace/instr/BTraceProbePersistedTest.java`, that feeds an
+     invalid recursive persisted probe (and a general failed-verification case where practical),
+     asserts `isVerified()` is false, and asserts `checkVerified()` throws `VerifierException`
+     containing `execution.loop.danger`. Include a valid persisted control if the fixture format
+     supports one.
+
+   **Gate:** legacy serialized input cannot enter the accepted agent path after a recursion or any
+   bytecode-verification failure merely because the boolean result was ignored; recursive input
+   retains the `execution.loop.danger` diagnostic at `checkVerified()`.
+
+4. Inspect compatibility and final behavior.
+
+   - Confirm non-trusted source and bytecode paths use the same `execution.loop.danger` message;
+     retain trusted/unsafe bypass and no-loop checks unrelated to call graphs.
+   - Confirm no new dependencies, public APIs, messages, packaging, or integration-test attach
+     scenario were introduced. This is a compiler/agent verification-boundary change; the focused
+     source and raw-bytecode tests prove both independently.
+
+   **Gate:** `git diff --check` is clean and every design acceptance condition is represented by a
+   source or bytecode regression test.
+
+## Verification sequence
+
+Run from the repository root with a workspace-local Gradle cache. Redirect every Gradle invocation
+to a file, filter it with `rg`, then read the filtered output. If a run hits the documented
+address-selection issue, retry that exact command with
+`JAVA_TOOL_OPTIONS="-Djava.net.preferIPv4Stack=true -Djava.net.preferIPv6Addresses=false"`.
+
+```bash
+GRADLE_USER_HOME=$(pwd)/.gradle-user ./gradlew :btrace-compiler:test > /tmp/btrace-issue-890-compiler.log 2>&1
+rg -n "BUILD (SUCCESSFUL|FAILED)|FAILED|ERROR|BTraceDslVerifierTest|tests" /tmp/btrace-issue-890-compiler.log
+```
+
+```bash
+GRADLE_USER_HOME=$(pwd)/.gradle-user ./gradlew :btrace-agent:test > /tmp/btrace-issue-890-agent.log 2>&1
+rg -n "BUILD (SUCCESSFUL|FAILED)|FAILED|ERROR|CallGraphTest|MethodVerifierDslTest|BTraceProbePersistedTest|tests" /tmp/btrace-issue-890-agent.log
+```
+
+```bash
+GRADLE_USER_HOME=$(pwd)/.gradle-user ./gradlew :btrace-compiler:spotlessCheck :btrace-agent:spotlessCheck > /tmp/btrace-issue-890-spotless.log 2>&1
+rg -n "BUILD (SUCCESSFUL|FAILED)|FAILED|ERROR|spotless" /tmp/btrace-issue-890-spotless.log
+```
+
+Finish with `git diff --check` and inspect the diff for unintended verifier-policy or descriptor
+changes.
+
+## Stop conditions
+
+- Stop and return to design review if correct source attribution cannot distinguish same-class
+  overloads, if a proposed graph would reject external/static utility calls, or if handler
+  reachability cannot be maintained.
+- Stop if persisted-format rejection requires changing trusted/unsafe policy, a wire-format
+  migration, or a public BTrace API; determine a compatible rejection point first.
+- Stop if complete protection requires inter-class, virtual, reflection, callback, or
+  invokedynamic analysis; those are explicitly outside #890.
+- Do not commit until all applicable gates and verification commands pass, unless the user
+  explicitly directs otherwise.
+
+## Completion criteria
+
+- Source verification rejects handler-reachable direct and mutual same-script recursion with
+  `execution.loop.danger`, while acyclic helpers and overload controls pass.
+- Node and persisted agent bytecode paths reject the same recursive input; a false persisted
+  `isVerified()` result always makes `checkVerified()` reject with the original or deterministically
+  reproduced `execution.loop.danger` failure rather than silently succeeding.
+- Agent graph edges are owner-filtered and direct self-loops are detected without false positives.
+- Compiler tests, agent tests, scoped Spotless, and final diff inspection are clean; no trusted
+  policy, descriptor, packaging, runtime, or protocol scope changes are present.
diff --git a/internal/specs/2026-07-18-issue-890-recursion-verification.md b/internal/specs/2026-07-18-issue-890-recursion-verification.md
new file mode 100644
index 000000000..798599526
--- /dev/null
+++ b/internal/specs/2026-07-18-issue-890-recursion-verification.md
@@ -0,0 +1,92 @@
+# Issue #890: recursion verification
+
+Date: 2026-07-18  
+Status: implementation contract for [#890](https://github.com/btraceio/btrace/issues/890)
+
+## Scope
+
+Reject handler-reachable direct and mutual same-script recursion at both verification boundaries.
+This restores the bounded/no-loops guarantee before a hot probe can consume application-thread
+stack and CPU.
+
+## Two-layer design
+
+### Source verifier
+
+Extend `btrace-compiler/src/main/java/io/btrace/compiler/VerifierVisitor.java` with a per-BTrace
+class graph collected from attributed `MethodInvocationTree` nodes. Use resolved
+`ExecutableElement` caller/callee identity, including descriptor/signature identity for overloads,
+and add an edge only when the callee is declared by the same BTrace class. Mark BTrace-annotated
+handler methods as roots. After scanning the class, reject a direct self-edge or any cycle
+reachable from a handler root.
+
+This keeps valid acyclic helpers legal and excludes BTrace utilities, services, JDK calls, and
+other-class calls from script graph edges. Trusted/unsafe scripts retain their current verifier
+bypass; #890 does not change that policy.
+
+### Agent bytecode verifier
+
+Keep the agent as the authoritative defense for bytecode that bypasses source compilation. In
+`btrace-agent/src/main/java/io/btrace/instr/BTraceMethodNode.java`, record graph edges only for
+`INVOKESTATIC` calls whose translated owner is the probe's own class, retaining method-name plus
+descriptor IDs and existing handler roots. In
+`btrace-agent/src/main/java/io/btrace/instr/CallGraph.java`, make a singleton self-edge a cycle
+instead of discarding every graph smaller than two nodes. The class-level `Verifier.visitEnd()`
+continues its whole-class, reachability-aware decision after all methods have been read.
+
+Apply the equivalent whole-class check to an accepted persisted-probe verifier path in
+`BTraceProbePersisted`, so serialized legacy input cannot bypass the agent safety boundary. Do not
+turn a same-named external static call into a local edge; owner filtering prevents false positives.
+`BTraceProbePersisted.isVerified()` currently converts `VerifierException` into `false`; correct
+the accepted `checkVerified()` path so a false bytecode-verification result is rejected by throwing
+the verification failure rather than being silently discarded. A detected persisted recursive cycle
+must therefore fail the same agent acceptance boundary as a node-format probe.
+
+## Error behavior and compatibility
+
+Both layers fail with the existing `execution.loop.danger` message and normal mechanisms
+(compiler diagnostic/failed compilation; agent `VerifierException`). Diagnostics may add method or
+cycle context. No runtime `StackOverflowError` handling, target behavior, options, or protocol
+change is needed.
+
+## Affected components and tests
+
+- Extend `btrace-compiler/src/test/java/io/btrace/compiler/BTraceDslVerifierTest.java`: direct
+  recursion and two-method mutual recursion fail with `execution.loop.danger`; acyclic helpers and
+  overload-distinct non-cycles pass.
+- Update `CallGraph`, `BTraceMethodNode`, class-level agent `Verifier`, and the persisted verifier
+  path. Extend `btrace-agent/src/test/java/io/btrace/instr/CallGraphTest.java` for a singleton
+  self-loop.
+- Extend `btrace-agent/src/test/java/io/btrace/instr/MethodVerifierDslTest.java` (or a focused
+  sibling) with handcrafted non-trusted direct and mutual recursive probe bytes. Factory creation
+  plus `checkVerified()` must throw `VerifierException` containing `execution.loop.danger`; an
+  acyclic same-class helper must pass. Add a persisted-format regression that proves a failed
+  `isVerified()` result, including a recursive cycle, makes `checkVerified()` throw/reject rather
+  than return successfully.
+
+These two verifier-layer tests are the acceptance boundary: source tests prove normal compilation
+fails before submission, while bytecode tests prove the agent rejects crafted/precompiled input.
+
+## Non-goals
+
+- Do not ban all same-class helpers, trusted-script recursion, or legal acyclic calls.
+- Do not analyze inter-class, virtual/interface, reflection, invokedynamic, callback, or
+  extension-service graphs.
+- Do not alter tutorial wording already corrected, allocation/autoboxing policy, runtime guards,
+  instrumentation, attach behavior, or release packaging.
+
+## Acceptance, verification, and boundaries
+
+- Both layers reject non-trusted direct and mutual handler-reachable recursion; acyclic helpers
+  continue to compile and verify.
+- The agent rejects raw/precompiled recursive bytecode even if source verification is bypassed.
+- Persisted probes whose verification fails are rejected by `checkVerified()`; they cannot pass
+  merely because `isVerified()` returned `false` after catching `VerifierException`.
+- Run `:btrace-compiler:test` and `:btrace-agent:test`, plus scoped Spotless checks, with
+  `GRADLE_USER_HOME=$(pwd)/.gradle-user`. Redirect each Gradle command to a log, filter with `rg`
+  for selected tests, failures, and `BUILD SUCCESSFUL`, and inspect the filtered output. Retry the
+  documented address-selection failure with the repository IPv4 `JAVA_TOOL_OPTIONS`.
+
+This is a backward-compatible safety tightening for non-trusted scripts. It changes no credentials,
+permissions, authentication, networking, target authorization, protocol, or release boundary. It
+prevents user-provided script bytecode from creating unbounded stack/CPU pressure on traced threads.


Test patch:
diff --git a/btrace-agent/src/test/btrace/onmethod/ArgsUnsafe.java b/btrace-agent/src/test/btrace/onmethod/ArgsUnsafe.java
index cfa9b95c7..9537fc1c8 100644
--- a/btrace-agent/src/test/btrace/onmethod/ArgsUnsafe.java
+++ b/btrace-agent/src/test/btrace/onmethod/ArgsUnsafe.java
@@ -42,7 +42,7 @@ public static void args(@Self Object self, String a, long b, String[] c, int[] d
       println(la + " # " + t);
     } catch (Throwable e) {
       println("FAILED");
-      println(e.getMessage());
+      println(e);
     }
   }
 }
diff --git a/btrace-agent/src/test/btrace/onmethod/leveled/ArgsUnsafe.java b/btrace-agent/src/test/btrace/onmethod/leveled/ArgsUnsafe.java
index 25bda190c..1a5406a14 100644
--- a/btrace-agent/src/test/btrace/onmethod/leveled/ArgsUnsafe.java
+++ b/btrace-agent/src/test/btrace/onmethod/leveled/ArgsUnsafe.java
@@ -43,7 +43,7 @@ public static void args(@Self Object self, String a, long b, String[] c, int[] d
       println(la + " # " + t);
     } catch (Throwable e) {
       println("FAILED");
-      println(e.getMessage());
+      println(e);
     }
   }
 }
diff --git a/btrace-agent/src/test/java/io/btrace/instr/CallGraphTest.java b/btrace-agent/src/test/java/io/btrace/instr/CallGraphTest.java
index edf1f1f2e..899cd14d9 100644
--- a/btrace-agent/src/test/java/io/btrace/instr/CallGraphTest.java
+++ b/btrace-agent/src/test/java/io/btrace/instr/CallGraphTest.java
@@ -74,4 +74,13 @@ void hasCycle() {
 
     assertTrue(callGraph.hasCycle());
   }
+
+  @Test
+  void hasCycleForHandlerSelfLoop() {
+    CallGraph callGraph = new CallGraph();
+    callGraph.addStarting("handler");
+    callGraph.addEdge("handler", "handler");
+
+    assertTrue(callGraph.hasCycle());
+  }
 }
diff --git a/btrace-agent/src/test/java/io/btrace/instr/MethodVerifierDslTest.java b/btrace-agent/src/test/java/io/btrace/instr/MethodVerifierDslTest.java
index 82a12fca3..c3f626858 100644
--- a/btrace-agent/src/test/java/io/btrace/instr/MethodVerifierDslTest.java
+++ b/btrace-agent/src/test/java/io/btrace/instr/MethodVerifierDslTest.java
@@ -17,10 +17,14 @@
 package io.btrace.instr;
 
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import io.btrace.core.SharedSettings;
 import io.btrace.core.VerifierException;
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
 import org.junit.jupiter.api.Test;
 import org.objectweb.asm.AnnotationVisitor;
 import org.objectweb.asm.ClassWriter;
@@ -77,4 +81,119 @@ void indyWithUnknownBootstrap_failsVerifier() {
     assertThrows(
         VerifierException.class, () -> verifyProbeBytes(probeWithIndy("com/evil/Bootstrap")));
   }
+
+  @Test
+  void directRecursionFailsVerifier() {
+    assertRecursionFails(probeWithHandlerCall("TestProbe", "handler"));
+  }
+
+  @Test
+  void mutualRecursionFailsVerifier() {
+    ClassWriter cw = newProbeWriter();
+    MethodVisitor handler = handler(cw, "handler");
+    handler.visitMethodInsn(Opcodes.INVOKESTATIC, "TestProbe", "helper", "()V", false);
+    handler.visitInsn(Opcodes.RETURN);
+    handler.visitMaxs(0, 0);
+    handler.visitEnd();
+    MethodVisitor helper = cw.visitMethod(Opcodes.ACC_STATIC, "helper", "()V", null, null);
+    helper.visitCode();
+    helper.visitMethodInsn(Opcodes.INVOKESTATIC, "TestProbe", "handler", "()V", false);
+    helper.visitInsn(Opcodes.RETURN);
+    helper.visitMaxs(0, 0);
+    helper.visitEnd();
+    cw.visitEnd();
+
+    assertRecursionFails(cw.toByteArray());
+  }
+
+  @Test
+  void acyclicSameClassHelperPassesVerifier() {
+    ClassWriter cw = newProbeWriter();
+    MethodVisitor handler = handler(cw, "handler");
+    handler.visitMethodInsn(Opcodes.INVOKESTATIC, "TestProbe", "helper", "()V", false);
+    handler.visitInsn(Opcodes.RETURN);
+    handler.visitMaxs(0, 0);
+    handler.visitEnd();
+    MethodVisitor helper = cw.visitMethod(Opcodes.ACC_STATIC, "helper", "()V", null, null);
+    helper.visitCode();
+    helper.visitInsn(Opcodes.RETURN);
+    helper.visitMaxs(0, 0);
+    helper.visitEnd();
+    cw.visitEnd();
+
+    assertDoesNotThrow(() -> verifyProbeBytes(cw.toByteArray()));
+  }
+
+  @Test
+  void sameNamedExternalStaticCallDoesNotCreateLocalCycle() {
+    VerifierException exception =
+        assertThrows(
+            VerifierException.class,
+            () -> verifyProbeBytes(probeWithHandlerCall("io/btrace/External", "handler")));
+    assertFalse(exception.getMessage().contains("endless loop"));
+  }
+
+  @Test
+  void persistedRecursiveProbeRetainsVerificationFailure() throws Exception {
+    boolean trusted = SharedSettings.GLOBAL.isTrusted();
+    try {
+      SharedSettings.GLOBAL.setTrusted(true);
+      BTraceProbeFactory trustedFactory = new BTraceProbeFactory(SharedSettings.GLOBAL);
+      BTraceProbeNode recursiveProbe =
+          (BTraceProbeNode)
+              trustedFactory.createProbe(probeWithHandlerCall("TestProbe", "handler"));
+      BTraceProbePersisted persisted = BTraceProbePersisted.from(recursiveProbe);
+      ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+      persisted.write(new DataOutputStream(bytes));
+
+      SharedSettings.GLOBAL.setTrusted(false);
+      BTraceProbe restored =
+          new BTraceProbeFactory(SharedSettings.GLOBAL).createProbe(bytes.toByteArray());
+      assertFalse(restored.isVerified());
+      assertRecursionFails(restored);
+    } finally {
+      SharedSettings.GLOBAL.setTrusted(trusted);
+    }
+  }
+
+  private void assertRecursionFails(byte[] bytes) {
+    assertRecursionFails(() -> verifyProbeBytes(bytes));
+  }
+
+  private static void assertRecursionFails(BTraceProbe probe) {
+    assertRecursionFails(probe::checkVerified);
+  }
+
+  private static void assertRecursionFails(Runnable verification) {
+    VerifierException exception = assertThrows(VerifierException.class, verification::run);
+    assertTrue(exception.getMessage().contains("endless loop"));
+  }
+
+  private static ClassWriter newProbeWriter() {
+    ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
+    cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, "TestProbe", null, "java/lang/Object", null);
+    cw.visitAnnotation("Lio/btrace/core/annotations/BTrace;", true).visitEnd();
+    return cw;
+  }
+
+  private static MethodVisitor handler(ClassWriter cw, String name) {
+    MethodVisitor mv =
+        cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, name, "()V", null, null);
+    AnnotationVisitor av = mv.visitAnnotation("Lio/btrace/core/annotations/OnMethod;", true);
+    av.visit("clazz", "java.lang.String");
+    av.visitEnd();
+    mv.visitCode();
+    return mv;
+  }
+
+  private static byte[] probeWithHandlerCall(String owner, String name) {
+    ClassWriter cw = newProbeWriter();
+    MethodVisitor handler = handler(cw, "handler");
+    handler.visitMethodInsn(Opcodes.INVOKESTATIC, owner, name, "()V", false);
+    handler.visitInsn(Opcodes.RETURN);
+    handler.visitMaxs(0, 0);
+    handler.visitEnd();
+    cw.visitEnd();
+    return cw.toByteArray();
+  }
 }
diff --git a/btrace-compiler/src/test/java/io/btrace/compiler/BTraceDslVerifierTest.java b/btrace-compiler/src/test/java/io/btrace/compiler/BTraceDslVerifierTest.java
index 4dc2b6a0f..62ac96c57 100644
--- a/btrace-compiler/src/test/java/io/btrace/compiler/BTraceDslVerifierTest.java
+++ b/btrace-compiler/src/test/java/io/btrace/compiler/BTraceDslVerifierTest.java
@@ -18,6 +18,8 @@
 
 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.PrintWriter;
 import java.io.StringWriter;
@@ -80,4 +82,62 @@ void handlerFailureTriggerPassesSourceVerifier() {
     assertNotNull(result, "Compilation should succeed: " + err);
     assertFalse(result.isEmpty(), "Should produce class bytes");
   }
+
+  @Test
+  void directHandlerRecursionFailsSourceVerifier() {
+    assertRecursionFails(
+        "DirectRecursionProbe.java",
+        "import io.btrace.core.annotations.*;\n"
+            + "@BTrace public class DirectRecursionProbe {\n"
+            + "  @OnMethod(clazz=\"java.lang.String\", method=\"length\")\n"
+            + "  public static void handler() { handler(); }\n"
+            + "}\n");
+  }
+
+  @Test
+  void mutualHandlerRecursionFailsSourceVerifier() {
+    assertRecursionFails(
+        "MutualRecursionProbe.java",
+        "import io.btrace.core.annotations.*;\n"
+            + "@BTrace public class MutualRecursionProbe {\n"
+            + "  @OnMethod(clazz=\"java.lang.String\", method=\"length\")\n"
+            + "  public static void handler() { helper(); }\n"
+            + "  static void helper() { handler(); }\n"
+            + "}\n");
+  }
+
+  @Test
+  void acyclicOverloadedHelpersPassSourceVerifier() {
+    StringWriter err = new StringWriter();
+    Map<String, byte[]> result =
+        new Compiler()
+            .compile(
+                "AcyclicOverloadProbe.java",
+                "import io.btrace.core.annotations.*;\n"
+                    + "@BTrace public class AcyclicOverloadProbe {\n"
+                    + "  @OnMethod(clazz=\"java.lang.String\", method=\"length\")\n"
+                    + "  public static void handler() { helper(); }\n"
+                    + "  static void helper() { helper(1); }\n"
+                    + "  static void helper(int value) {}\n"
+                    + "}\n",
+                new PrintWriter(err),
+                null,
+                System.getProperty("java.class.path"));
+    assertNotNull(result, "Compilation should succeed: " + err);
+    assertFalse(result.isEmpty(), "Should produce class bytes");
+  }
+
+  private static void assertRecursionFails(String fileName, String source) {
+    StringWriter err = new StringWriter();
+    Map<String, byte[]> result =
+        new Compiler()
+            .compile(
+                fileName,
+                source,
+                new PrintWriter(err),
+                null,
+                System.getProperty("java.class.path"));
+    assertNull(result, "Compilation should fail: " + err);
+    assertTrue(err.toString().contains("endless loop"), "Expected recursion diagnostic: " + err);
+  }
 }