fix(engine)!: refuse engine calls from the response allocation - #2446
Conversation
Closes tari-project#2442. `handle` writes every engine call's response into WASM memory through the template's own `tari_alloc`, so servicing a call runs template code — while an invocation is in flight, where engine calls are permitted. A `tari_alloc` that calls the engine therefore cycles host -> WASM -> host once per response and exhausts the native stack, aborting the validator on SIGSEGV rather than rejecting the transaction. Nothing bounded it. `max_call_depth` never saw it: no `CallInvoke`, no new `WasmProcess`, one call frame throughout. Metering did not get there first either — a `tari_alloc` body costs on the order of tens of points against a 100M per-call ceiling, millions of rounds past what the stack survives. Only `EmitLog` is count-capped, and ops like `GenerateUniqueId` are not. tari-project#2440 closed the two entry points that run outside an invocation, the `CallInfo` allocation and the return-value free. This closes the third by the same means: the invocation window is shut for the duration of the response allocation, so all three places the engine drives template code are now unable to call back into it. A refusal is drained on both paths out of the invocation, not just the trap path. `tari_engine_entrypoint` can only answer a refused call with a null pointer, and a template that ignores it returns normally, so checking only the trap path let the refusal be dropped and the transaction commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UURir3p3vkw4cS48CDz3UX
sdbondi
left a comment
There was a problem hiding this comment.
Checked out the branch, traced the control flow, and ran the tests. The fix is correct, and I verified the new test is load-bearing: with the exit/enter bracket removed from alloc_response, test_engine_call_in_response_alloc aborts with fatal runtime error: stack overflow (SIGABRT); with it, all three guard tests pass.
What I verified
- Coverage is complete. Four places the engine drives WASM:
alloc_checked(CallInfo, before the window),free_checked(return ptr, after it),alloc_response(new), andfunc.callitself. Grepping.call(inprocess.rsfinds no fifth, andwith_alloc_and_mem_writeris only used for theCallInfo. enter_template_invocation()after the alloc restores rather than sets, becausehandleis only reachable past the entrypoint guard atcrates/engine/src/wasm/process.rs:250, which returns null when the window is closed.- Nesting cannot corrupt the bool.
invoke_template(crates/engine/src/transaction/processor.rs:939) builds a freshStore+WasmProcessper cross-template call, so eachWasmEnvsees exactly one invocation. - Draining
last_engine_erroron both paths is strictly additive.template_abi::call_enginepanics on a null response (crates/template_abi/src/abi/mod.rs:67), so a swallowed engine error only arises for a template that ignores the ABI. No legitimate flow is newly rejected. - Error precedence survives the hoist. The engine error is still checked ahead of the panic message and the out-of-gas classification. An out-of-gas trap inside
alloc_responsesurfaces asWasmExecutionError::WasmRuntimeError, notRuntimeError, so it is not stored inlast_engine_errorandFeeIntentComputeExceeded/InsufficientFeesForComputestill win in theErrarm. - Hardening not called out in the description:
len as u32becameu32::try_from. The oldWasmEnv::alloccall site truncated ausizefromencoded_len, so a >=4 GiB response would have allocated a truncated buffer thatencode_into_writerthen wrote past. Unreachable in a wasm32 process, but good to have closed.
Worth resolving before merge
1. This implements option 3, but the issue leans to option 2. #2442 lists three directions, and the comment there is "leaning to option 2 because that is the more correct way (removes a class of bugs and slightly reduces WASM size to boot), we can tolerate ABI breaking changes at this stage." This PR does option 3 (guard the alloc) and closes the issue. Option 3's own stated downside is that it "does not bound other re-entrant shapes if any exist" — the invariant now lives in three near-duplicate hand-written brackets (alloc_checked, free_checked, alloc_response) rather than at the boundary, so a fourth host->WASM call site added later has to remember. Either keep #2442 open as the follow-up for option 2, or record in the PR body why option 3 won. As written, the decision is lost when the issue auto-closes.
Minor
2. handle is pub (crates/engine/src/wasm/process.rs:361) but the bracket assumes it is not reachable outside the window. Nothing outside process.rs calls it (grepped crates/ and applications/). Since the signature is changing anyway, making it private — or saving and restoring the flag instead of the unconditional enter_template_invocation() — turns "safe because of a guard three functions away" into "safe by construction".
3. take_refused_engine_call at crates/engine/src/wasm/process.rs:512 cannot fire. A refusal is only recorded while the window is closed, and every site that closes it (alloc_checked, free_checked, alloc_response) drains it before returning, including on the trap path since the drain precedes result?. In the new test the refusal reaches invoke as last_engine_error, not as a pending refusal. Harmless belt-and-braces, but the comment above it ("A refusal or engine error recorded during the invocation fails the call on both paths") reads as if the refusal arm is live.
4. rustfmt: three lines now exceed max_width = 120 in crates/engine/tests/templates/buggy/src/lib.rs (lines 27, 59 and 93, at 121-126 chars). CI will not catch it because that crate declares its own [workspace] and root cargo fmt --all does not reach it, but the file was inside the limit before.
5. Module name. engine_call_outside_invocation now hosts a variant that runs inside an invocation — the engine just closes the window around it. The doc comment explains this well; the name is the only thing left saying otherwise.
Nothing here blocks except deciding (1).
Generated by Claude Code
… than reopen Address review on tari-project#2446. `alloc_response` reopened the window unconditionally, which is only correct because `handle` sits behind the entrypoint guard. Capture the previous state and restore it, so the bracket is right wherever it is called from, and make `handle` private — it has no callers outside this module. Order the two checks in `invoke` by what actually happens: a swallowed engine error is the live case, and the refusal drain is a backstop for a window-closing site added later without one. Wrap the feature cfgs in the `buggy` template that exceeded the line limit. Root `cargo fmt --all` does not reach that crate, which declares its own workspace. Rename its `engine_call_outside_invocation` module: one of its variants now runs inside an invocation, with the engine closing the window around it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UURir3p3vkw4cS48CDz3UX
sdbondi
left a comment
There was a problem hiding this comment.
Reviewed bf680c1. All five points from the previous review are addressed, and the option-2 section in the description is substantive — I reproduced the sizing data it rests on. One new minor suggestion.
Verified on this commit
suspend/restoregenuinely generalizes the bracket.alloc_responseno longer depends on the entrypoint guard atcrates/engine/src/wasm/process.rs:245for correctness, which is what I was after.handleis private and nothing outsideprocess.rsreferenced it; the crate builds.- The check reorder in
invokeis behaviourally identical today. A refusal recorded while the window is closed is always drained by the site that closed it, including on the trap path since the drain precedesresult?, sorefused_engine_callisNoneby the timeinvokelooks. The new test reachesinvokethroughlast_engine_error, not through the refusal arm. The revised comment now says exactly that rather than implying the arm is live. - Module rename is complete.
grep -rn engine_call_outside_invocationover the tree is empty, and the header comment at the top ofbuggy/src/lib.rstracks the new name. - No line in
buggy/src/lib.rsexceeds 120 any more. The two diffscargo +nightly-2025-12-05 fmt --checkstill reports in that crate (the two_ABI_TEMPLATE_DEFarray literals) are both pre-existing ondevelopment, so this PR now introduces none. cargo test -p tari_engine: 4 test binaries, 37 tests, 0 failures.cargo clippy -p tari_engine --all-targets: clean — the twotoo_many_argumentswarnings come fromcrates/engine_types/src/resource.rs:68,99and are pre-existing.
On the option-2 rationale
I checked the numbers that carry the argument:
max_memory_pages: 32(~2 MiB) andmax_substate_size: 1024 * 1024atcrates/engine_types/src/limits.rs:19and:128, so "half the memory budget to a buffer" is accurate.- The generated dispatcher does issue
component_manager.get_state::<..>()per method call (crates/template_macros/src/template/dispatcher.rs:144), so a host-owned region really would be sized by the largest component state on the hottest path. - Reproduced the size distribution. Instrumented
handleto recordencoded_len(&resp)and ran the full engine suite: 6,341 samples, median 7 B, p90 43 B, p99 728 B, max 1,965 B. (I dropped 15 lines torn by concurrent test binaries appending to one file — the two apparent outliers above 2 KiB were both39repetitions.) That matches the median 7 / max 1,964 in the description, and confirms 4 KiB covers everything observed with room to spare.
The per-allocation point cost (~126) and the 0.4-1.2% of transaction WASM points I did not reproduce.
New (minor)
#[must_use] on suspend_template_invocation. The point of this commit is that the bracket is correct wherever it is called from — but a caller that drops the returned bool leaves the window closed for the remainder of the invocation, so every later engine call in it is refused and the transaction rejects. unused_must_use is denied in lints.toml:21, so the attribute turns that into a build failure instead of a silently wrong rejection. restore_template_invocation taking a plain bool means nothing else flags it.
Generated by Claude Code
Closes #2442, which has the mechanism and the impact.
Why option 3 and not option 2
#2442 leaned to option 2 — take response allocation away from the template entirely. I designed it
out, and it does not survive contact:
ComponentManager::get_state()returns the whole componentstate, and the generated dispatcher issues it on every method call, so a host-owned region is
sized by the largest state any template holds, on the hottest path. A template's entire linear
memory is 2 MiB (
max_memory_pages = 32), andmax_substate_sizeis 1 MiB, so preserving today'ssemantics means handing half the memory budget to a buffer.
#[template(io_region_kib = N)]was the answerto that, with a write-side check to stop a template writing state larger than the region that has
to read it back — otherwise a component is written successfully and then permanently uncallable.
But
GetStatecan read another template's component, so the writer's region is not thereader's. The check has to be against a global ceiling, which puts a single fixed cap straight
back and leaves the knob buying nothing.
destination, host writes into it — requires the template to size the buffer before it knows the
response length, and engine ops cannot be re-run to find out. The host has to hold the encoded
response between two calls. Multipart is the same stash with more round trips.
So option 2 as scoped would have shipped a knob that does not knob, a new consensus check, and a
hard ABI break requiring every template to be republished — to remove a class of bugs that option 3
closes for three lines.
The performance argument does not carry it either. I measured the response allocation across the
engine suite: 6,341 of them, median 7 bytes, p99 692, max 1,964 — nothing near any plausible region
size. Each costs ~126 metered points, which is ~1% of a transaction's WASM points (0.4-1.2%
across sampled transactions), or ~2% counting the matching free. Real, but not what an ABI break is
for.
What option 3 does not do is retire the class: the engine still calls template-supplied
tari_allocand
tari_free, and the invariant lives in three hand-written brackets rather than at the boundary.Worth its own issue if it is picked up later — the sizing data above says the right shape is a small
region (4 KiB covers everything observed) with a fallback, not a region sized for the worst case.
Implementation
handlewrites each engine call's response through the template'stari_alloc, so servicing a callruns template code while an invocation is in flight. That allocation moves into
alloc_response,which shuts the invocation window around it:
The window is restored rather than reopened, so the bracket is correct wherever it is called from
rather than only behind the entrypoint guard.
Same mechanism #2440 used for the two entry points that already ran outside an invocation, so all
three places the engine drives template code are now closed to engine calls.
handletakes theFunctionEnvMutrather than a pre-split(&mut WasmEnv, StoreMut). It needs tomutate the environment either side of a call that re-enters WASM, and the environment must not stay
borrowed across that call — the refusal is recorded through the engine's own
&mutto it. Thedispatch sites in
tari_engine_entrypointpass&mut env, and the guard/meter split is scoped to ablock ahead of them.
WasmEnv::allocis gone;alloc_responseis the only caller left and it needs the borrow split.Refusals are drained on both paths
A refusal now fails the call before the
Ok/Errmatch rather than only on the trap path.tari_engine_entrypointcan answer a refused call only with a null pointer, and a template is freeto ignore that and return normally — which is exactly what the new test template does. Checking only
the trap path let the refusal be dropped and the transaction commit.
This also picks up the case flagged in review on #2440: an engine error raised mid-invocation and
swallowed by a template no longer disappears.
Test
test_engine_call_in_response_allocadds a thirdbuggyvariant whosetari_alloccalls the engineonce an invocation is under way — a static flag skips the
CallInfoallocation, which #2440 alreadyrefuses before the invocation begins, so the response allocation is the one that runs. It ignores the
null it gets back and returns normally.
Verified load-bearing: with the bracket removed the test aborts the process with
fatal runtime error: stack overflow(SIGABRT). With it, the transaction rejects withEngineCallOutsideInvocation.cargo test -p tari_enginepasses in full (36 suites);cargo lints clippyclean.Breaking
Consensus-affecting: a transaction whose template calls the engine from
tari_allocduring aninvocation previously crashed the node and now rejects.