fix(engine)!: reject engine calls made outside a template invocation - #2440
Conversation
|
Reviewed at Verified sound
Two things I'd change before merge. 1. The invariant is coupled to the metering lifecycle, and the stated follow-up breaks it
Make the invariant its own state (an explicit 2.
|
…ndow Address review on tari-project#2440. `has_invocation_in_flight()` read `invocation_meter.is_some()`, which only distinguishes a template function invocation from the `tari_alloc`/`tari_free` around it because `end_metered_invocation()` happens to run before the free. Widening the metering window — to charge the alloc and free the engine drives, say — would silently re-admit the calls this refuses. Track the invocation window as its own state, opened and closed around `func.call` alone. Record the refusal in its own slot rather than in `last_engine_error`, which the normal dispatch path also writes. Sharing it meant a `RuntimeError` raised mid-invocation and swallowed by a template that ignored the null pointer now surfaced out of `free_checked`, misreported as an illegal engine call and rejecting a transaction that previously succeeded — a second consensus change that was not the intent of this branch. Cover the `tari_alloc` half with its own test. It is the more dangerous side: before the guard, an engine call from `tari_alloc` recursed host to WASM and back once per response allocation and aborted the process with a stack overflow, since the response allocation calls `tari_alloc` again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UURir3p3vkw4cS48CDz3UX
|
Reviewed Addressed
Two new findings. 1. The recursion is still open, and it is a node crash
The machinery added in env_mut.exit_template_invocation();
let ptr = env_mut.alloc(&mut store, len as u32);
env_mut.enter_template_invocation();
take_refused_engine_call(env_mut)?;
let ptr = ptr?;That also makes the rule uniform and matches what the PR title already claims: no engine call from 2.
|
…loc/tari_free Address review on tari-project#2440. `alloc_checked`/`free_checked` held the `&mut WasmEnv` from `data_and_store_mut()` across the call into the template's `tari_alloc` or `tari_free`. A template that calls `tari_engine` from either one has wasmer hand `tari_engine_entrypoint` a second `&mut` to that same environment, which it writes the refusal through while the outer borrow is still live and later read from — the path `test_engine_call_in_tari_alloc` exercises. Interior mutability used to make this benign, since the host held a distinct `WasmEnv` whose fields were shared behind `Arc`; taking the fields down to plain values made the aliasing real. Clone the exported function out of the environment, drop the borrow, call, then re-borrow to drain the refusal. `WasmEnv::alloc` keeps its `&self` form for `handle`, which receives the borrow from its caller and so cannot release it here; that call site is where the response-allocation recursion lives and is being addressed separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UURir3p3vkw4cS48CDz3UX
The engine enters WASM outside of any invocation twice per call: once to run the template's `tari_alloc` when staging the `CallInfo`, and once to run its `tari_free` on the pointer the template function returned. A template that calls `tari_engine` from either one reached the full engine op set from a context the runtime attributes to no invocation. Both halves of that are wrong. The effects commit — an `EmitLog` issued from `tari_free` lands in the finalized result — and the WASM that produced them is never charged, because `invoke` ends the invocation meter and records its consumption before freeing the return pointer, so the mid-call meter sync in `tari_engine_entrypoint` is a no-op. Refuse the call when no invocation is in flight, a condition the engine already tracks in `WasmEnv::invocation_meter`. The entrypoint can only signal a refusal by returning a null pointer, which a template is free to ignore and return normally, so the refusal is recorded as the last engine error and surfaced by the host once `tari_alloc`/`tari_free` return. This does not close the unbilled compute itself: a `tari_free` that burns cycles without calling the engine is still uncharged, bounded only by the instance's leftover wasmer allowance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UURir3p3vkw4cS48CDz3UX
…ndow Address review on tari-project#2440. `has_invocation_in_flight()` read `invocation_meter.is_some()`, which only distinguishes a template function invocation from the `tari_alloc`/`tari_free` around it because `end_metered_invocation()` happens to run before the free. Widening the metering window — to charge the alloc and free the engine drives, say — would silently re-admit the calls this refuses. Track the invocation window as its own state, opened and closed around `func.call` alone. Record the refusal in its own slot rather than in `last_engine_error`, which the normal dispatch path also writes. Sharing it meant a `RuntimeError` raised mid-invocation and swallowed by a template that ignored the null pointer now surfaced out of `free_checked`, misreported as an illegal engine call and rejecting a transaction that previously succeeded — a second consensus change that was not the intent of this branch. Cover the `tari_alloc` half with its own test. It is the more dangerous side: before the guard, an engine call from `tari_alloc` recursed host to WASM and back once per response allocation and aborted the process with a stack overflow, since the response allocation calls `tari_alloc` again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UURir3p3vkw4cS48CDz3UX
`WasmProcess` kept its own clone of `WasmEnv` alongside the one wasmer owns, so every piece of shared state had to sit behind an `Arc<Mutex<_>>` for writes made through one copy to be visible from the other. The engine runs one instance at a time on one thread, so the locking bought nothing. `FunctionEnv::as_mut` hands out `&mut T` to whoever holds the store, and `FunctionEnvMut::data_and_store_mut` splits the borrow where the environment and the store are both needed at once — as the host call handlers already do. Hold the `FunctionEnv` handle instead of a clone and take the fields down to plain `Option`/`bool`. `on_panic_handler` now returns the message out of the memory-slice closure and records it afterwards, rather than mutating the environment from inside a closure that borrows it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UURir3p3vkw4cS48CDz3UX
…loc/tari_free Address review on tari-project#2440. `alloc_checked`/`free_checked` held the `&mut WasmEnv` from `data_and_store_mut()` across the call into the template's `tari_alloc` or `tari_free`. A template that calls `tari_engine` from either one has wasmer hand `tari_engine_entrypoint` a second `&mut` to that same environment, which it writes the refusal through while the outer borrow is still live and later read from — the path `test_engine_call_in_tari_alloc` exercises. Interior mutability used to make this benign, since the host held a distinct `WasmEnv` whose fields were shared behind `Arc`; taking the fields down to plain values made the aliasing real. Clone the exported function out of the environment, drop the borrow, call, then re-borrow to drain the refusal. `WasmEnv::alloc` keeps its `&self` form for `handle`, which receives the borrow from its caller and so cannot release it here; that call site is where the response-allocation recursion lives and is being addressed separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UURir3p3vkw4cS48CDz3UX
7d47c2c to
b6e23d2
Compare
…invoke Reaching the environment through the `FunctionEnv` handle spelled every access `self.fn_env.as_ref(store)`, which rustfmt breaks across four lines. That pushed `invoke` past the `too_many_lines` threshold. Name the two accessors `env`/`env_mut` so a read fits on one line, and lift the metering allowance into its own function returning a `MeteringAllowance`. The block computing it was self-contained already: what remains in `invoke` is the call, the accounting around it and the result handling. `env_mut` was the borrow-splitting helper; it is now `env_and_store`, which is what it does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UURir3p3vkw4cS48CDz3UX
|
Reviewed
Rebase — compared each rewritten commit's patch against its original: identical modulo hunk offsets, except the one real conflict in
Nit: Two things before merge, neither about the code:
The remaining |
Description
The engine enters WASM outside of any invocation twice per call: once to run the template's
tari_allocwhen staging theCallInfo, and once to run itstari_freeon the pointer the template function returned. A template that callstari_enginefrom either one reaches the full engine op set from a context the runtime attributes to no invocation.From
tari_free— the effects commit and the compute is free. AnEmitLogissued there lands in the finalized result, and the WASM that produced it is never charged:invokecallsend_metered_invocation()andrecord_wasm_execution(...)beforeenv.free(...), so with the meter torn down the mid-call sync intari_engine_entrypointreturnsNone. Measured: 200k volatile loop iterations added totari_freeleftwasm_execution_pointsat 116 — byte-identical to the empty version.From
tari_alloc— the node dies. The host allocates each engine-call response throughtari_alloc, so atari_allocthat calls the engine recurses host→WASM→host once per response. Verified against this branch with the guard disabled:thread has overflowed its stack / fatal runtime error: stack overflow, aborting(SIGABRT).max_call_depthdoes not apply — it is all one call frame — and the recursion is driven by the host, so the wasmer meter does not bound it either.There is no recursion in the
tari_freecase, incidentally: the engine never frees the response to an engine call (the template owns it), sofree -> engine call -> freedoes not cycle. That call simply succeeds where it should not.Fix
Refuse the call in
tari_engine_entrypointwhen no template function invocation is in flight. New error:RuntimeError::EngineCallOutsideInvocation { op }.The window is tracked as its own state (
WasmEnv::in_template_invocation), opened and closed aroundfunc.callalone, deliberately not derived frominvocation_meter. The two coincide today only becauseend_metered_invocation()runs before the free; widening the metering window later — to charge the alloc and free the engine drives, say — must not silently re-admit these calls.The entrypoint can only signal a refusal by returning a null pointer, and a template is free to ignore that and return normally, so the refusal is recorded in its own slot (
WasmEnv::refused_engine_call) and surfaced host-side byalloc_checked/free_checked, which wrap the two entries into WASM that happen outside an invocation. The slot is kept separate fromlast_engine_error, which the normal dispatch path writes: sharing it would make a mid-invocation error swallowed by a template surface as though the free had made an illegal engine call, rejecting a transaction that previously succeeded.handle'senv_mut.allocis untouched: that runs mid-invocation, where the meter is live and allocation is legitimate.Tests
Two cases added to the
buggytemplate suite —test_engine_call_in_tari_freeandtest_engine_call_in_tari_alloc. Each builds a template that callstari_enginefrom that hook, ignores the null it gets back, and returns normally; both transactions are rejected withEngineCallOutsideInvocation.The variants supply their own
tari_alloc/tari_freeand deliberately do not linktari_template_abi, whosetari_freewould collide on the#[no_mangle]symbol. They carry a hardcoded_ABI_TEMPLATE_DEFdeclaring one function so the template is callable and the engine actually reaches both hooks.cargo test -p tari_enginepasses in full (36 suites).Also in this branch
The
buggytemplate's extern block declareddebug, which matches no host import — the engine importstari_debug. Renamed, and given#[link(wasm_import_module = "env")]so the import resolves under the current wasm32 target rules. Unused by the existing variants, so inert for them; needed by the new ones.Not covered
The unbilled compute itself. A
tari_freethat burns cycles without calling the engine is still uncharged, bounded only by the instance's leftover wasmer allowance — and each instruction gets a fresh instance. Closing that means moving the accounting after the free, or opening a second metered window around it. Larger change, left out of this PR — and note the guard above is written so that change cannot reopen this hole.Host recursion from
tari_allocinside an invocation — tracked as #2442. Pre-existing and untouched:handleallocates every engine-call response through the template'stari_alloc, so the same host→WASM→host recursion is reachable from atari_alloccalled during a legitimate invocation, where engine calls are and must remain permitted. Verified to still abort the process with this branch applied.Breaking
Consensus-affecting: a transaction whose template calls the engine from
tari_alloc/tari_freepreviously committed (or crashed the node) and now rejects.