@@ -283,14 +283,48 @@ public class Wallet {
283283
284284 private static volatile Semaphore constantCallSemaphore ;
285285
286+ /**
287+ * Reentrancy flag for the constant-call concurrency cap. Only the OUTERMOST
288+ * {@link #callConstantContract} frame on a given thread takes/releases a
289+ * permit; inner frames (e.g., the binary-search loop inside
290+ * {@link #estimateEnergy}, or {@link #triggerContract} dispatching to a
291+ * view/pure method) read this flag and skip the throttle, so a single
292+ * top-level RPC occupies one slot for its full lifetime regardless of how
293+ * many internal VM invocations it performs. Issue #6681.
294+ *
295+ * <p>Memory-safety:
296+ * <ul>
297+ * <li>The field is {@code static final}, so the {@code WeakReference} key
298+ * inside each worker thread's {@code ThreadLocalMap} stays live for
299+ * the JVM lifetime — no stale-key cleanup story to worry about.</li>
300+ * <li>No {@code withInitial(...)}: a thread that never enters the
301+ * throttled path never has an entry created in its
302+ * {@code ThreadLocalMap}. {@link ThreadLocal#get() get()} returns
303+ * {@code null} for those threads.</li>
304+ * <li>The value is always {@code Boolean.TRUE} (a JVM-cached singleton).
305+ * The outermost frame {@link ThreadLocal#remove() remove()}s the
306+ * entry in {@code finally} so the map slot is reclaimed before the
307+ * worker returns to the gRPC/HTTP pool.</li>
308+ * </ul>
309+ *
310+ * <p>Correctness depends on the codebase's single-thread-per-request
311+ * invariant for both gRPC sync handlers (see {@code RpcService} which wires
312+ * a {@code newFixedThreadPool} into {@code NettyServerBuilder.executor}) and
313+ * the HTTP servlet handlers. Any future refactor that offloads VM work to
314+ * a different thread mid-request must re-evaluate this guard.
315+ */
316+ private static final ThreadLocal <Boolean > CONSTANT_CALL_PERMIT_HELD =
317+ new ThreadLocal <>();
318+
286319 private static Semaphore getConstantCallSemaphore () {
287320 Semaphore s = constantCallSemaphore ;
288321 if (s == null ) {
289322 synchronized (Wallet .class ) {
290323 s = constantCallSemaphore ;
291324 if (s == null ) {
292- s = new Semaphore (
293- CommonParameter .getInstance ().getConstantCallMaxConcurrency (), true );
325+ // Non-fair: tryAcquire() (no-arg) bypasses the fair queue regardless
326+ // of this flag, so requesting fairness here is misleading.
327+ s = new Semaphore (CommonParameter .getInstance ().getConstantCallMaxConcurrency ());
294328 constantCallSemaphore = s ;
295329 }
296330 }
@@ -3177,15 +3211,31 @@ public Transaction callConstantContract(TransactionCapsule trxCap,
31773211 throw new ContractValidateException ("this node does not support constant" );
31783212 }
31793213
3180- long networkMaxCpuTimeOfOneTxMs = chainBaseManager .getDynamicPropertiesStore ()
3181- .getMaxCpuTimeOfOneTx ();
3182- boolean throttled = VmTimeoutPolicy .isCapEngaged (
3183- CommonParameter .getInstance ().getConstantCallTimeoutMs (), networkMaxCpuTimeOfOneTxMs );
3184- Semaphore sem = throttled ? getConstantCallSemaphore () : null ;
3185- if (sem != null && !sem .tryAcquire ()) {
3186- throw new ContractValidateException (
3187- "too many concurrent constant calls; configured cap is "
3188- + CommonParameter .getInstance ().getConstantCallMaxConcurrency ());
3214+ // Reentrancy guard: a single top-level RPC may invoke this method many
3215+ // times on the same thread — estimateEnergy's binary search runs ~25-30
3216+ // iterations, and triggerContract dispatches view/pure functions through
3217+ // here. Only the OUTERMOST frame takes a permit; inner frames see the
3218+ // ThreadLocal flag and skip the throttle so per-RPC concurrency, not
3219+ // per-VM-call concurrency, is what we cap. Issue #6681.
3220+ Semaphore sem = null ;
3221+ boolean acquiredHere = false ;
3222+ if (!Boolean .TRUE .equals (CONSTANT_CALL_PERMIT_HELD .get ())) {
3223+ long networkMaxCpuTimeOfOneTxMs = chainBaseManager .getDynamicPropertiesStore ()
3224+ .getMaxCpuTimeOfOneTx ();
3225+ if (VmTimeoutPolicy .isCapEngaged (
3226+ CommonParameter .getInstance ().getConstantCallTimeoutMs (),
3227+ networkMaxCpuTimeOfOneTxMs )) {
3228+ sem = getConstantCallSemaphore ();
3229+ if (!sem .tryAcquire ()) {
3230+ // Throw before set(TRUE) so a failed acquire never pollutes the
3231+ // ThreadLocalMap.
3232+ throw new ContractValidateException (
3233+ "too many concurrent constant calls; configured cap is "
3234+ + CommonParameter .getInstance ().getConstantCallMaxConcurrency ());
3235+ }
3236+ CONSTANT_CALL_PERMIT_HELD .set (Boolean .TRUE );
3237+ acquiredHere = true ;
3238+ }
31893239 }
31903240 try {
31913241 Block headBlock ;
@@ -3236,7 +3286,11 @@ public Transaction callConstantContract(TransactionCapsule trxCap,
32363286 trxCap .setResult (ret );
32373287 return trxCap .getInstance ();
32383288 } finally {
3239- if (sem != null ) {
3289+ if (acquiredHere ) {
3290+ // remove() before release(): clears the slot from this worker's
3291+ // ThreadLocalMap so it goes back to the pool clean. Required because
3292+ // the field has no withInitial — only set/remove pairs touch the map.
3293+ CONSTANT_CALL_PERMIT_HELD .remove ();
32403294 sem .release ();
32413295 }
32423296 }
0 commit comments