Skip to content

[Feature] Standardize precompile execution with fine-grained timeout checks #6697

Description

@yanghang8612

Summary

Standardize execution across all TVM precompiled contracts, using a shared CPU-deadline policy, consistent timeout propagation, and resource cleanup. Add fine-grained internal checkpoints where the work warrants them, including iterative computation, multi-stage verification, and parallel tasks.

The common execution pattern applies to every precompile. Internal checkpoint placement should reflect the cost and structure of each implementation, including its helper methods. Lightweight operations can use common entry/exit checks; more substantial work needs checks between meaningful units of execution.

Motivation

Precompiles cover several execution shapes: fixed-work operations, collection traversal, iterative cryptographic computation, staged library calls, and parallel verification. Their execution wrappers, exception handling, and resource management have evolved separately.

A shared pattern would make the VM's CPU-budget contract easier to implement, review, and test across these shapes. BatchValidateSign and VerifyTransferProof remain the initial parallel-execution integration points, while the broader source review supports applying the same principles throughout the precompile set.

Proposed Solution

1. Establish a common execution lifecycle

Introduce a shared wrapper, for example execute(...) delegating to a precompile-specific doExecute(...), with:

  • Entry and exit deadline checks under the applicable VM execution policy.
  • Internal checkpoints in the implementation or its helpers where appropriate.
  • Consistent propagation of OutOfTimeException through the public execution boundary.
  • Cleanup of resources and unfinished tasks owned by the invocation.

Preserve each precompile's ABI, result encoding, and ordinary input-validation and error semantics. Treat elapsed-time enforcement separately from existing fork-gated validation rules.

2. Use one deadline throughout the invocation

Sequential stages, helper methods, parallel workers, and blocking waits should use the same execution deadline. Each wait consumes the remaining budget rather than starting a new one.

Align the clock, units, expiration boundary, and timeout-enable policy with the VM. Validate all supported execution modes, including transaction execution, constant calls, replay, and debug operation. Workers that need checkpoints should receive the invocation's execution context explicitly.

3. Match checkpoint granularity to the work

Use the source inventory in the follow-up analysis to guide implementation:

  • Internal execution changes: integrate checkpoints and task lifecycle handling into iterative, staged, or parallel work.
  • Further granularity evaluation: use benchmarks to decide whether chunked computation provides enough benefit to justify the additional checks.
  • Common-wrapper integration: retain simple implementations and small bounded traversals unless measurements justify additional internal checks.

Review shared parsing, signature, proof, and computation helpers as well as the visible execute(...) methods. Place checkpoints between meaningful work units and around library calls where supported, while preserving cryptographic and protocol behavior.

This is cooperative deadline enforcement. Checkpoint placement and cancellation must respect the capabilities of the underlying computation libraries; immediate interruption of every operation is not an implementation guarantee.

4. Share parallel execution and cleanup

Provide common handling for submission, deadline-aware waiting, result collection, and cancellation. Apply the same deadline throughout this lifecycle and make cleanup cover unsuccessful exits, including interruption and partial submission.

Preserve the caller's interrupt status where applicable, maintain the distinction between timeout and ordinary task failure, and propagate worker exceptions according to the common execution policy. Cancellation of running work is best effort and should be tested separately from cancellation of queued work.

Validation

  • Verify common entry/exit behavior and timeout propagation through wrappers, helpers, and parallel execution.
  • Use a controllable clock and isolated executors for deterministic lifecycle and cleanup tests.
  • Cover sequential, staged, and parallel implementations, including successful execution and ordinary validation failures.
  • Compare result encoding, receipts, energy accounting, state, and logs where applicable across supported execution modes.
  • Benchmark representative supported workloads to select checkpoint frequency and assess overhead.
  • Validate replay compatibility and activation requirements before rollout.

Impact and Compatibility

The intended outcome is a consistent precompile execution contract, appropriate internal deadline checks, and maintainable task/resource handling.

No precompile address, ABI, or energy-pricing change is intended. Normal successful and ordinary validation behavior should be preserved. Changes to timeout handling and checkpoint placement still require compatibility testing; consensus neutrality and the absence of a fork gate must not be assumed in advance.

The source inventory is an implementation-planning assessment. Final checkpoint placement and scope remain subject to implementation, regression testing, and performance measurements.

References

Additional Notes

  • Do you have ideas regarding implementation? Yes.
  • Are you willing to implement this feature? Yes.

Activity

  1. freddie-lele commented on Apr 21, 2026

    @freddie-lele

    @yanghang8612 Thanks for the detailed analysis. Aligning the timeout handling across parallel precompiles makes the codebase much more predictable for anyone building on or auditing the core logic. I especially like the idea of a 'shared helper' to standardize the execution pattern for future parallel precompile tasks. This kind of symmetry is key to a robust VM implementation.

  2. alan-eth commented on Apr 22, 2026

    @alan-eth

    Really appreciate that the test plan already covers both sides of the timeout path — "latch exhausted → notEnoughTime" and "cancelled futures reported as cancelled". That second one is a nice touch in particular, since it pins down the actual cancel(true) behavior rather than just the exception being thrown. If useful, two low-priority scenarios that might complement those nicely:

    1. Queued tasks after cancel. Since workersInNonConstantCall is a fixed 5-thread pool and shielded transactions can easily submit more tasks than that, some do end up queued in practice. Wondering if it'd be worth a quick probe (e.g., an AtomicInteger incremented inside a test-only task subclass's call()) to confirm queued-but-not-yet-started tasks really don't run once the timeout fires. Would turn the "releases worker slots" benefit from a structural claim into a verifiable fact. Totally optional — feel free to skip if it feels like overkill for this change.

    2. Mixed completion states at timeout. Could be nice to have a scenario or two where await(...) == false happens while some futures have already returned — one with all-true partials, one with mixed true/false. Mostly a safety net: guards against a future refactor accidentally letting control slip to the checkResult fold or insertLeaves when the latch already timed out. Not strictly needed to validate this change, more about locking in the eager-throw contract for later.

    Either way, really looking forward to seeing this one land. 🚀

  3. yanghang8612 commented on Apr 22, 2026

    @yanghang8612
    CollaboratorAuthor

    @freddie-lele Thanks! Glad the symmetry framing lands. Given the point about future parallel-verification precompiles inheriting the same semantics, I'll fold both call-sites onto the shared helper in the same PR rather than leaving it as an optional follow-up.

  4. yanghang8612 commented on Apr 22, 2026

    @yanghang8612
    CollaboratorAuthor

    @alan-eth Thanks — both are worth having and I'll fold them into the test plan.

    For (1), I'll submit more subtasks than workersInNonConstantCall has threads and use a test-only subclass with an AtomicInteger.incrementAndGet() at the top of call(), then assert the counter stays ≤ pool size after the latch timeout fires. FutureTask.cancel(true) flips queued tasks to CANCELLED before a worker dequeues them, so call() shouldn't be entered for anything still queued — the counter makes that observable.

    For (2), I'll add both the all-true and mixed true/false partial cases with await(...) == false, asserting Program.Exception.notEnoughTime surfaces and neither the checkResult fold nor insertLeaves runs.

    Really appreciate the careful read.

  5. waynercheung commented on Apr 22, 2026

    @waynercheung
    Collaborator

    @yanghang8612
    One implementation detail looks worth pinning down before the helper lands.

    Today the observable difference between these two precompiles is not quite "BatchValidateSign already surfaces OOT while VerifyTransferProof does not". BatchValidateSign.doExecute() does throw on latch timeout, but BatchValidateSign.execute() currently catches Throwable and returns zero bytes, so the OutOfTimeException does not escape the public precompile boundary either. VerifyTransferProof.execute() also swallows Throwable. From the VM call-site, the real divergence today is that VerifyTransferProof can still block the block-processing thread on future.get() after the CPU budget is already exhausted, while BatchValidateSign avoids that extra wait.

    That suggests a couple of things for the shared helper / PR shape:

    1. If the intended end-state is "the precompile itself raises OOT", both execute() wrappers need to passthrough OutOfTimeException explicitly (similar to ValidateMultiSign). Otherwise the helper can throw internally and still get swallowed at the outer boundary.
    2. The cancel path should also cover getCPUTimeLeftInNanoSecond() itself throwing before await() begins. In that case the futures have already been submitted, so cancelling only on await(...) == false still leaves submitted work running.
    3. I would also cancel outstanding futures on InterruptedException for the same resource-release reason.

    One nuance on the proposed queued-task test: with the current input constraints, a single VerifyTransferProof call can submit at most 5 tasks (2 spend + 2 output + 1 binding), which exactly matches the fixed pool size of 5. So deterministic per-call queueing is not observable through the existing end-to-end input shape. If we want to verify queued futures becoming CANCELLED, a small package-private helper test with a dedicated tiny executor would be much more deterministic than an end-to-end proof test, and it avoids flakiness from the shared static pools.

    Also, for the PR description I’d phrase cancel(true) as best-effort reclamation for running native verification calls, while treating “queued tasks do not start” as the stronger guarantee.

  6. Federico2014 commented on Apr 22, 2026

    @Federico2014
    Collaborator

    Additional concern: solidity-node vs full-node state divergence from the precompile-internal timeout

    Before landing the proposed change, I'd like to flag a pre-existing asymmetry that this PR would unintentionally extend to VerifyTransferProof.

    TVM has two independent wall-clock timeout checks:

    1. Opcode-level — Program.checkCPUTimeLimit (Program.java:1241-1260) short-circuits on solidity nodes and debug mode:

      if (CommonParameter.getInstance().isDebug())        { return; }
      if (CommonParameter.getInstance().isSolidityNode()) { return; }

      This is intentional: solidity nodes aren't on the consensus path and must reproduce mainnet state regardless of local replay latency.

    2. Precompile-internal — PrecompiledContracts.getCPUTimeLeftInNanoSecond (PrecompiledContracts.java:458-465) has no such short-circuit. It is used by BatchValidateSign at L1091 and by VerifyTransferProof at L1478 to bound a CountDownLatch.await(...).

    Because vmShouldEndInUs is still computed normally on solidity nodes (VMActuator.java:401-405), the await can genuinely time out on a solidity node under GC / disk / scheduling pressure even when the same transaction succeeded on the producing SR.

    For BatchValidateSign, the timeout path is:

    • L1091 await returns withNoTimeout=false
    • L1095 throws notEnoughTime
    • L1041-1046 outer catch (Throwable) swallows it and returns Pair.of(true, new byte[32]) (all-zero result)
    • Control returns to the VM loop; the next opcode's checkCPUTimeLimit short-circuits on a solidity node, so execution continues with the zero-byte result
    • The contract branches on a value that differs from mainnet → divergent SSTORE / LOG / REVERT → persisted state diverges from the SR's block

    On a full node the same timeout would also land in the outer catch, but the very next checkCPUTimeLimit throws OOT and the transaction reverts cleanly — so only the solidity node silently forks.

    VerifyTransferProof is currently not affected: it ignores withNoTimeout and falls through to future.get(), so workers always finish and the real result is returned (solidity node is just slower). This PR's proposal to have it honor withNoTimeout and short-circuit on timeout would remove that safety property and introduce the same divergence surface that BatchValidateSign already has.

    Suggested fix

    Gate the precompile-internal timeout on the same condition as checkCPUTimeLimit, so the two checks stay in sync:

    protected long getCPUTimeLeftInNanoSecond() {
      CommonParameter p = CommonParameter.getInstance();
      if (p.isDebug() || p.isSolidityNode()) {
        return Long.MAX_VALUE;
      }
      long left = getVmShouldEndInUs() * VMConstant.ONE_THOUSAND - System.nanoTime();
      if (left <= 0) {
        throw Program.Exception.notEnoughTime("call");
      }
      return left;
    }

    Effect:

    • Full nodes: behavior unchanged — both precompiles still time out as today / as proposed.
    • Solidity / debug nodes: precompile workers are awaited to completion, matching the existing checkCPUTimeLimit short-circuit semantics. The cost is that a heavy replay transaction can take longer on a solidity node, but that is already the accepted trade-off for every other opcode on the replay path.

    This one change covers both precompiles and closes the divergence for BatchValidateSign at the same time as making the VerifyTransferProof refactor safe to land.

    Happy to open a separate issue for the BatchValidateSign part if the maintainers prefer to keep this PR focused.

  7. alan-eth commented on Apr 23, 2026

    @alan-eth

    @yanghang8612 Perfect — thanks for folding both in, and the FutureTask.cancel(true) framing for queued tasks is exactly right. One small heads-up though: @waynercheung's point below is a useful guardrail on the pool-saturation scenario — a single VerifyTransferProof call caps at 5 submitted tasks (2 spend + 2 output + 1 binding), which exactly equals workersInNonConstantCall's pool size, so the queued-vs-dequeued distinction isn't really observable through an end-to-end proof-shaped input.

  8. yanghang8612 commented on Apr 24, 2026

    @yanghang8612
    CollaboratorAuthor

    @waynercheung Thanks — that tightens the framing more than the issue body does. You're right that "BatchValidateSign surfaces OOT, VerifyTransferProof does not" isn't the real-world divergence: both precompiles' public execute(...) catch Throwable and return zeros, so the OutOfTimeException never escapes the precompile boundary in either case. The observable divergence at the VM call-site is strictly that VerifyTransferProof parks the block-processing thread on future.get() until the slowest worker returns, while BatchValidateSign does not.

    I'll rewrite the issue to match:

    • Current State. Frame it as "BatchValidateSign.doExecute short-circuits the post-latch path when the CPU budget is exhausted; VerifyTransferProof.execute does not" — drop the notEnoughTime-escaping language, since the outer Throwable catch makes that invisible either way.
    • Impact. Keep the consensus-neutral claim (still true — zero-bytes return plus opcode-level checkCPUTimeLimit on the next iteration produce the same receipt either way), but narrow the concrete benefit to "block-thread no longer blocks on future.get() past the CPU budget" + "worker cancellation stops orphaned verification computations".

    That way the issue's framing matches what the PR will actually assert in code.

  9. yanghang8612 commented on Apr 24, 2026

    @yanghang8612
    CollaboratorAuthor

    @Federico2014 Very real concern, and the asymmetry you're pointing at is already present today on BatchValidateSign.doExecute — uncritically mirroring the pattern into VerifyTransferProof extends the same surface to shielded proof verification rather than fixing it.

    Two things to pin down on the PR:

    1. Verify the asymmetry doesn't produce consensus-visible divergence today. The saving grace is that both precompiles' public execute(...) catch Throwable and return zeros, so a precompile-internal OutOfTimeException never reaches VMActuator.execute and never writes anything to the receipt. For a full-node vs. solidity-node state disagreement to surface on a consensus-visible field, the precompile would have to mutate state before the latch timeout fires — neither shielded proof verification nor batch signature verification does. I'll add a fixture-level regression test for both precompiles: drive getCPUTimeLeftInNanoSecond to zero, run on a solidity-node-flagged VM, and assert contractResult / energyUsed / state / logs match the full-node replay byte-for-byte.

    2. If (1) holds, keep this issue's scope narrow. Aligning the two precompiles stays consensus-neutral, and the pool-thread fix is the real behavior change on the full-node side. If (1) surfaces any divergence, the fix is bigger than this issue — it'd mean adding the isSolidityNode() short-circuit inside the precompile latch as well, not just at the opcode check — and I'd raise that as a separate follow-up rather than fold it in.

    Will confirm (1) with the fixture before opening the PR. Good catch either way.

  10. yanghang8612 commented on Apr 24, 2026

    @yanghang8612
    CollaboratorAuthor

    @alan-eth Good heads-up. Counting submitted tasks for a single shielded transaction: 2 × spend + 2 × output + 1 × binding = 5, exactly workersInNonConstantCall. So through a real proof-shaped input, nothing queues and the "queued-but-not-dequeued" invariant isn't observable end-to-end.

    I'll still keep the AtomicInteger subclass test for the mechanical invariant, but drive it through a synthetic submit() loop that oversubscribes the pool beyond 5 rather than through a shaped ShieldedTRC20 input — same assertion, just not claiming it reflects a real-world scenario. For BatchValidateSign the queued case is observable with a well-shaped input (N can be large), so the real-input coverage lives there.

  11. yanghang8612 commented on May 15, 2026

    @yanghang8612
    CollaboratorAuthor

    Quick update: after reviewing the v4.8.2 (Pyrrho) release tracking, we've decided not to include this change in v4.8.2.

    Reason: while the refactor itself is small and self-contained, v4.8.2's scope is already locked (the release_v4.8.2 branch has been cut from develop). We'd prefer to bundle this with a broader CPU-budget / precompile-timeout review in the next release rather than slipping it in late. The issue stays open and the work can still proceed in parallel so it's ready when the v4.8.3 window opens.

  12. self-assigned this
    on Jun 5, 2026
  13. yanghang8612 commented on Sep 2, 2026

    @yanghang8612
    CollaboratorAuthor

    A quick update on this issue: this improvement is planned for the next java-tron release. Development and related validation are currently underway, and the final implementation may still be adjusted as the work progresses.

  14. changed the title [-][Feature] Align timeout and task-cancellation handling across parallel verification precompiles[/-] [+][Feature] Standardize precompile execution with fine-grained timeout checks[/+] on Sep 8, 2026
  15. yanghang8612 commented on Sep 8, 2026

    @yanghang8612
    CollaboratorAuthor

    Following a source review of the 34 precompile implementations and their relevant helpers, I have refined the issue body to distinguish common execution standardization from implementation-specific checkpoint work. The current title still describes the scope well.

    The inventory below was cross-checked against the relevant code on develop at 57b7b04. These groups reflect execution structure and proposed engineering work; their ordering is not a measured performance ranking.

    A. Internal execution and lifecycle work — 9 precompiles

    Precompile(s) Proposed work
    BatchValidateSign, VerifyTransferProof Integrate the shared parallel-task lifecycle, remaining-budget waits, result collection, exception propagation, and cleanup. Also cover the serial execution path where applicable and subsequent processing stages.
    ValidateMultiSign Add consistent checkpoints between signature-processing units while preserving existing validation and activation rules.
    BN128Pairing, Blake2F Integrate checkpoints into the computation helpers at suitable work boundaries. Select the frequency through benchmarks rather than checking only the outer precompile wrapper.
    VerifyMintProof, VerifyBurnProof Add checks between verification stages and preserve native-context cleanup. Apply the shared Merkle-processing policy where that stage is used.
    RewardBalance Carry the execution policy into reward-calculation helpers and check between collection-processing units that include storage access.
    ModExp Cover parsing, computation, and result-processing boundaries using the common policy. Any deeper computation changes need a separate assessment of library support, correctness, and cost.

    B. Further checkpoint-granularity evaluation — 4 precompiles

    Precompile(s) Proposed assessment
    Sha256, Ripempd160, EthRipemd160 Evaluate chunked digest processing under the shared deadline, retaining the exact existing algorithms, crypto-engine selection, and output semantics.
    BN128Multiplication Evaluate whether additional checkpoints within the bounded arithmetic work are worthwhile beyond common entry/exit checks.

    C. Common-wrapper integration first — 21 precompiles

    Precompile(s) Initial treatment
    Identity, ECRecover, BN128Addition, MerkleHash, P256Verify Use the common execution boundary for direct-return or fixed-work operations. Preserve ordinary result/error behavior and consistent timeout propagation wherever checkpoints are introduced.
    VoteCount, UsedVoteCount Keep the small in-memory traversals simple; add internal checks only if their measured cost justifies them.
    AvailableUnfreezeV2Size, ExpireUnfreezeBalanceV2 Include the associated collection helpers in the review, with checkpoint frequency appropriate to normal bounded state.
    TotalVoteCount, UnfreezableBalanceV2, DelegatableResource, ResourceV2, CheckUnDelegateResource, TotalResource Cover the common boundary and associated account/resource helpers; avoid adding clock reads to every small accessor without evidence of benefit.
    IsSrCandidate, ReceivedVoteCount, GetChainParameter, ResourceUsage, TotalDelegatedResource, TotalAcquiredResource Start with common entry/exit checks for their fixed set of queries and calculations.

    Common-wrapper integration is part of the implementation scope for all three groups. Group C does not mean that a precompile is excluded from the change.

    Shared integration work

    • Establish one invocation-scoped deadline and the applicable VM execution policy, including the context passed to workers.
    • Review shared parsing, signature-recovery, proof/Merkle, and cryptographic helpers for checkpoint placement and exception propagation. Preserve initialization behavior and existing protocol rules.
    • Preserve the distinction between timeout, interruption, and ordinary verification failure; make task and resource cleanup consistent across exit paths.
    • Keep checks cooperative and describe cancellation according to the underlying library's capabilities.

    Validation should combine deterministic clock/executor tests, existing functional fixtures, supported execution-mode and replay checks, and benchmarks for checkpoint overhead. The proposed scope does not imply that compatibility has already been established: activation requirements and final checkpoint placement remain to be confirmed by that work. This qualification also supersedes the earlier assumptions in this discussion about consensus neutrality and immediate reclamation of running computations.

  16. lxcmyf commented on Sep 8, 2026

    @lxcmyf
    Collaborator

    The broader inventory makes sense, but rollout granularity still looks important. Applying the common wrapper to all 34 precompiles while also adding internal checkpoints to the heavier implementations makes any timeout-behavior regression difficult to isolate. Would it be safer to split this into a wrapper-only phase with characterization tests for all precompiles, followed by checkpoint/lifecycle changes grouped by execution shape? Any phase that changes an observable timeout result could then be benchmarked and evaluated for fork activation independently.

  17. yanghang8612 commented on Sep 11, 2026

    @yanghang8612
    CollaboratorAuthor

    @lxcmyf That is a reasonable approach. A phased rollout should make behavioral changes easier to review and validate.

    We will treat common-wrapper integration and characterization tests as the first phase, then evaluate implementation-specific checkpoint and lifecycle changes separately by execution shape. Any phase that may change observable timeout behavior will require its own benchmarking, compatibility review, and activation assessment before inclusion. This issue can remain the overall tracker while the implementation work is divided into smaller, reviewable changes.

  18. removed this from the GreatVoyage-v4.8.3 milestone on Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

  • Status
    No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions