feat(rasc): errors test cases and thrown messages in script errors (#14532) bd0142048b * feat(runtime): rive_rt_v1.error carries a thrown message into the trap * feat(rasc): sync throws hand their message to the host before trapping * feat(rasc): errors cases in the AnimaScript Tests harness * feat(cli): test runners settle errors cases and report thrown messages * feat(scripting): test.errors in the Luau Tester and the editor runner * feat(scripting_workspace): translate test.errors to the AnimaScript errors case * test(cli): errors cases cover the AnimaScript std error paths * feat(rasc): --no-throw-messages compiles throws back to a bare trap * feat(cli): published AnimaScript bakes drop throw messages * fix(runtime): a throw at module start fails the boot, traps mark script throws and can run quiet * fix(rasc): null and empty throw messages, stripped throws still type check, only std's __riveThrow reports * fix(cli): errors cases settle only on a script throw and keep their expected traps quiet * style(scripting_workspace): restore the test body comment above emitTestBody * test(cli): errors cases for the remaining AnimaScript std throw sites * test(cli): run the tests_demo sample and pin its pass and fail counts * test(rive_core): run errors cases through the Luau runner * test(rasc): spell thrown nullables T? and cover throws built with ?., ?? and ! * refactor(cli): run both test lanes through the editor's shared harness * fix(cli): a runaway Luau test case fails on its budget instead of hanging * fix(rive_core): tell the execution budget apart by a flag, not its error text * fix(rasc): stripped throws compile nothing of their message, a first pass with messages checks them * fix(runtime): a throw while a tier swap reruns start traps without a vm, boot errors keep their full text * fix(scripting_workspace): stripped AnimaScript bakes check throw messages with a first compile that keeps them * fix(cli): keep the Luau test budget armed through swallowed errors, declarations and timed runtime calls * fix(rive_native): keep the Luau execution budget armed until the timed call ends fix(cli): editor-only enum converter, path effect placement, headless key focus, print separator (#14536) a840503cbe CLI fixes: - Editor-only DataConverterEnumToUint corrupted converter wiring - ScriptedPathEffect under a Shape failed with a bare re-import error - Headless --key as the first interaction saw nothing focused - print() concatenated its arguments Co-authored-by: Luigi Rosso <luigi-rosso@users.noreply.github.com> Co-authored-by: Philip Chung <philterdesign@gmail.com>
diff --git a/.rive_head b/.rive_head index 35d7f61..719658c 100644 --- a/.rive_head +++ b/.rive_head
@@ -1 +1 @@ -b80a7fc662e92e5cb8f6daa80a6427d08f1d7b06 +bd0142048bde516d4d6be3468b5971e0b1ff731e
diff --git a/include/rive/lua/rive_lua_libs.hpp b/include/rive/lua/rive_lua_libs.hpp index 2cd51aa..45e925f 100644 --- a/include/rive/lua/rive_lua_libs.hpp +++ b/include/rive/lua/rive_lua_libs.hpp
@@ -2663,10 +2663,16 @@ return; } lua_Callbacks* cb = lua_callbacks(state); + if (m_timedDepth++ == 0) + { + m_outerInterrupt = cb->interrupt; + } cb->interrupt = interruptCPP; executionTime = std::chrono::steady_clock::now(); } + // Restores rather than clears, so a host's own interrupt, like the test + // harness budget, outlives any timed call it makes. void endTimedExecution(lua_State* state) { if (m_timeoutMs == 0) @@ -2674,7 +2680,7 @@ return; } lua_Callbacks* cb = lua_callbacks(state); - cb->interrupt = nullptr; + cb->interrupt = --m_timedDepth == 0 ? m_outerInterrupt : interruptCPP; } #ifdef WITH_RIVE_TOOLS @@ -2692,6 +2698,8 @@ private: int m_timeoutMs = 200; + int m_timedDepth = 0; + void (*m_outerInterrupt)(lua_State* L, int gc) = nullptr; #ifdef WITH_RIVE_TOOLS ConsoleCallback m_consoleCallback = nullptr; VectorBinaryStream m_consoleBuffer;
diff --git a/include/rive/wasm/rive_bindings_v1.h b/include/rive/wasm/rive_bindings_v1.h index 69f0022..0f2b917 100644 --- a/include/rive/wasm/rive_bindings_v1.h +++ b/include/rive/wasm/rive_bindings_v1.h
@@ -222,6 +222,8 @@ void rive_rt_mark_needs_update(uint32_t object); RIVE_BINDING_IMPORT("rive_rt_v1", "budget_exceeded") void rive_rt_budget_exceeded(uint32_t ms); +RIVE_BINDING_IMPORT("rive_rt_v1", "error") +void rive_rt_error(const char* message, uint32_t length); RIVE_BINDING_IMPORT("rive_rt_v1", "debug_enter") void rive_rt_debug_enter(uint32_t func, uint32_t line); RIVE_BINDING_IMPORT("rive_rt_v1", "debug_line")
diff --git a/include/rive/wasm/wasm_scripting_vm.hpp b/include/rive/wasm/wasm_scripting_vm.hpp index 0c1d6d2..8c2c0a0 100644 --- a/include/rive/wasm/wasm_scripting_vm.hpp +++ b/include/rive/wasm/wasm_scripting_vm.hpp
@@ -428,8 +428,30 @@ /// Ends module execution like a trap once the current native returns. virtual void raiseModuleError(const char* message); - /// The full raiseModuleError text when `exception` is its truncation - /// (the runtime's exception buffer is small), otherwise `exception`. + /// raiseModuleError for a spent execution budget, which lastTrap marks. + void raiseBudgetExceeded(); + + /// raiseModuleError for a message the script threw, which lastTrap marks. + void raiseThrown(const char* message); + + /// What ended the last call that trapped: the message the script threw, + /// else the runtime's own trap text. A test takes only a thrown trap for + /// the error it expected. + struct Trap + { + std::string message; + bool budget = false; + bool thrown = false; + }; + const Trap& lastTrap() const { return m_lastTrap; } + + /// Traps still land in lastTrap but print nothing, for callers that + /// expect them. + void setQuietTraps(bool quiet) { m_quietTraps = quiet; } + + /// The trap text without the runtime's "Exception: " prefix: the full + /// raiseModuleError text when `exception` is its truncation (the + /// runtime's exception buffer is small), otherwise `exception`. const char* fullTrapMessage(const char* exception) const; /// Function imports the module declares that no host native resolves. @@ -593,6 +615,10 @@ bool m_hostBudget = false; /// Set once the watchdog interrupts a call; the module never runs again. bool m_poisoned = false; + bool m_budgetRaised = false; + bool m_thrownRaised = false; + bool m_quietTraps = false; + Trap m_lastTrap; VisitSaves m_visitSaves; bool m_frameMinor = false; bool m_frameMinorAnnounced = false;
diff --git a/src/lua/lua_rive_base.cpp b/src/lua/lua_rive_base.cpp index fc6fabe..e548fb3 100644 --- a/src/lua/lua_rive_base.cpp +++ b/src/lua/lua_rive_base.cpp
@@ -22,6 +22,11 @@ luaL_tolstring(L, i, &l); // convert to string using __tostring et al + if (i > 1) + { + // Same separator as Luau's own print. + context->print(Span<const char>("\t", 1)); + } context->print(Span<const char>(s, l)); lua_pop(L, 1); // pop result }
diff --git a/src/wasm/idl/bindings.py b/src/wasm/idl/bindings.py index 8a5be1a..6464193 100644 --- a/src/wasm/idl/bindings.py +++ b/src/wasm/idl/bindings.py
@@ -238,11 +238,14 @@ ] -def op(name, params=(), ret=None, stub=None, guard=None): +def op(name, params=(), ret=None, stub=None, guard=None, boot=None): # guard: the host only carries the op when this macro is defined. Modules # import what they call, so one that never calls it links anywhere. + # boot: the op traps and module start can reach it, before the exec env + # carries the vm, so the WAMR wrapper resolves the booting one. With no + # vm at all, as a tier swap reruns start, it traps with this text. return {'name': name, 'params': list(params), 'ret': ret, 'stub': stub, - 'guard': guard} + 'guard': guard, 'boot': boot} def ns(module, short, ops): @@ -255,7 +258,10 @@ op('mark_needs_update', [handle('object')]), # Raised by the module's fuel checks when the execution budget # passes; the host prints the timeout and traps the op. - op('budget_exceeded', [u32('ms')]), + op('budget_exceeded', [u32('ms')], boot='execution exceeded timeout'), + # A script's throw hands over its message just before it traps, so + # the trap reports what was thrown. + op('error', [string('message', 'length')], boot='script threw'), # Line probes a debug bake plants: function entry and exit keep a # shadow call stack, and each line asks whether to stop. line # returns nonzero when it did, so the module re-arms its budget.
diff --git a/src/wasm/idl/generate.py b/src/wasm/idl/generate.py index b9dc89e..b090ac7 100644 --- a/src/wasm/idl/generate.py +++ b/src/wasm/idl/generate.py
@@ -324,7 +324,17 @@ call = '%sImpl(%s)' % (name, ', '.join(args)) body = ['%s %s(%s)' % (ret_ctype(op), name, ', '.join(params)), '{', - ' WasmScriptingVM* vm = vmFromEnv(env);'] + ' WasmScriptingVM* vm = %s(env);' % + ('bootVmFromEnv' if op['boot'] else 'vmFromEnv')] + if op['boot']: + assert op['ret'] is None, op['name'] + body += [' if (vm == nullptr)', + ' {', + ' wasm_runtime_set_exception(' + 'wasm_runtime_get_module_inst(env), "%s");' % + op['boot'], + ' return;', + ' }'] body += host_bounds_prologue(op) body += host_string_prologue(op['params']) body += [' %s%s;' %
diff --git a/src/wasm/wasm_natives_gen.hpp b/src/wasm/wasm_natives_gen.hpp index 1b729f7..a737940 100644 --- a/src/wasm/wasm_natives_gen.hpp +++ b/src/wasm/wasm_natives_gen.hpp
@@ -206,6 +206,7 @@ void rtLogImpl(WasmScriptingVM* vm, int32_t level, const char* message, uint32_t length); void rtMarkNeedsUpdateImpl(WasmScriptingVM* vm, uint32_t object); void rtBudgetExceededImpl(WasmScriptingVM* vm, uint32_t ms); +void rtErrorImpl(WasmScriptingVM* vm, const char* message, uint32_t length); void rtDebugEnterImpl(WasmScriptingVM* vm, uint32_t func, uint32_t line); uint32_t rtDebugLineImpl(WasmScriptingVM* vm, uint32_t line); void rtDebugLeaveImpl(WasmScriptingVM* vm); @@ -532,9 +533,25 @@ } void rtBudgetExceeded(wasm_exec_env_t env, uint32_t ms) { - WasmScriptingVM* vm = vmFromEnv(env); + WasmScriptingVM* vm = bootVmFromEnv(env); + if (vm == nullptr) + { + wasm_runtime_set_exception(wasm_runtime_get_module_inst(env), "execution exceeded timeout"); + return; + } rtBudgetExceededImpl(vm, ms); } +void rtError(wasm_exec_env_t env, const char* message, uint32_t length) +{ + WasmScriptingVM* vm = bootVmFromEnv(env); + if (vm == nullptr) + { + wasm_runtime_set_exception(wasm_runtime_get_module_inst(env), "script threw"); + return; + } + WasmStringArg messageUtf8(vm, message, length); + rtErrorImpl(vm, messageUtf8.data(), messageUtf8.size()); +} void rtDebugEnter(wasm_exec_env_t env, uint32_t func, uint32_t line) { WasmScriptingVM* vm = vmFromEnv(env); @@ -2131,6 +2148,7 @@ {"log", (void*)rtLog, "(i*~)", nullptr}, {"mark_needs_update", (void*)rtMarkNeedsUpdate, "(i)", nullptr}, {"budget_exceeded", (void*)rtBudgetExceeded, "(i)", nullptr}, + {"error", (void*)rtError, "(*~)", nullptr}, {"debug_enter", (void*)rtDebugEnter, "(ii)", nullptr}, {"debug_line", (void*)rtDebugLine, "(i)i", nullptr}, {"debug_leave", (void*)rtDebugLeave, "()", nullptr},
diff --git a/src/wasm/wasm_natives_web_gen.hpp b/src/wasm/wasm_natives_web_gen.hpp index 9c82424..10e282b 100644 --- a/src/wasm/wasm_natives_web_gen.hpp +++ b/src/wasm/wasm_natives_web_gen.hpp
@@ -34,6 +34,13 @@ rtBudgetExceededImpl(vm, ms); } EMSCRIPTEN_KEEPALIVE +void rive_web_rt_error(uint32_t vmHandle, const char* message, uint32_t length) +{ + auto vm = (WasmScriptingVM*)(uintptr_t)vmHandle; + WasmStringArg messageUtf8(vm, message, length); + rtErrorImpl(vm, messageUtf8.data(), messageUtf8.size()); +} +EMSCRIPTEN_KEEPALIVE void rive_web_rt_debug_enter(uint32_t vmHandle, uint32_t func, uint32_t line) { auto vm = (WasmScriptingVM*)(uintptr_t)vmHandle;
diff --git a/src/wasm/wasm_scripting_vm.cpp b/src/wasm/wasm_scripting_vm.cpp index 2330689..1731bd4 100644 --- a/src/wasm/wasm_scripting_vm.cpp +++ b/src/wasm/wasm_scripting_vm.cpp
@@ -152,7 +152,7 @@ // host still needs; the sink for that window is parked here. thread_local const std::function<void(const char*, size_t)>* s_bootPrint = nullptr; -// The VM whose module is starting, for the debug probes its top level hits. +// The VM whose module is starting, for the natives its top level reaches. thread_local WasmScriptingVM* s_booting = nullptr; thread_local WasmScriptingVM::BootHook s_bootHook; @@ -161,6 +161,7 @@ struct rive::WasmScriptingVMNatives { static void print(WasmScriptingVM* vm, const char* data, size_t size); + static WasmScriptingVM* adoptBooting(wasm_exec_env_t env); }; // Loaded modules are immutable and shared: fast-interp translation costs @@ -393,6 +394,9 @@ { return; } + // Paths that consume their own traps never reach reportTrap to clear it. + vm->m_budgetRaised = false; + vm->m_thrownRaised = false; // A debugger pauses mid call, so its sessions run unbudgeted. if (budgeted && vm->m_hostBudget && vm->m_timeoutMs > 0 && vm->m_debugHooks == nullptr) @@ -529,20 +533,29 @@ // A silent fold hides real traps; name them so a script that dies // mid-call is diagnosable instead of a mystery no-op. const char* exception = fullTrapMessage(wasm_runtime_get_exception(inst)); + // AOT code traps as unreachable; both tiers name the timeout. + m_lastTrap.budget = m_budgetRaised || + (m_budgetSlot != nullptr && m_budgetSlot->fired.load()); + m_lastTrap.thrown = m_thrownRaised && !m_lastTrap.budget; + m_budgetRaised = false; + m_thrownRaised = false; + if (m_lastTrap.budget) + { + exception = "execution exceeded timeout"; + } + m_lastTrap.message = exception != nullptr ? exception : ""; if (exception == nullptr) { return; } - // AOT code traps as unreachable; both tiers name the timeout. - if (m_budgetSlot != nullptr && m_budgetSlot->fired.load()) + if (!m_quietTraps) { - exception = "execution exceeded timeout"; - } - fprintf(stderr, "wasm call trapped in %s: %s\n", where, exception); + fprintf(stderr, "wasm call trapped in %s: %s\n", where, exception); #if WASM_ENABLE_DUMP_CALL_STACK != 0 - wasm_runtime_dump_call_stack(m_state->execEnv); + wasm_runtime_dump_call_stack(m_state->execEnv); #endif - if (m_leakWarningCount > 0 && !m_leakTrapContextPrinted) + } + if (m_leakWarningCount > 0 && !m_leakTrapContextPrinted && !m_quietTraps) { // A bare trap after leak warnings is almost always the memory // ceiling; say so once for hosts that dropped the warning strings. @@ -598,6 +611,18 @@ return wasm_runtime_addr_app_to_native(inst, appAddr); } +void WasmScriptingVM::raiseBudgetExceeded() +{ + m_budgetRaised = true; + raiseModuleError("execution exceeded timeout"); +} + +void WasmScriptingVM::raiseThrown(const char* message) +{ + m_thrownRaised = true; + raiseModuleError(message); +} + void WasmScriptingVM::raiseModuleError(const char* message) { // The runtime's exception buffer truncates long messages (shader @@ -608,9 +633,9 @@ const char* WasmScriptingVM::fullTrapMessage(const char* exception) const { - if (exception == nullptr || m_moduleErrorDetail.empty()) + if (exception == nullptr) { - return exception; + return nullptr; } const char* text = exception; constexpr char kPrefix[] = "Exception: "; @@ -621,12 +646,22 @@ // Substitute only when the exception is a truncation of the detail, so // an unrelated later trap keeps its own message. An empty remainder // matches every prefix and must not adopt stale detail. - if (*text != '\0' && + if (*text != '\0' && !m_moduleErrorDetail.empty() && m_moduleErrorDetail.compare(0, strlen(text), text) == 0) { return m_moduleErrorDetail.c_str(); } - return exception; + return text; +} + +WasmScriptingVM* WasmScriptingVMNatives::adoptBooting(wasm_exec_env_t env) +{ + // Instantiate assigns the same instance on success and null on failure. + if (s_booting != nullptr) + { + s_booting->m_state->instance = wasm_runtime_get_module_inst(env); + } + return s_booting; } void WasmScriptingVMNatives::print(WasmScriptingVM* vm, @@ -813,6 +848,14 @@ capacity); } +// Module start runs inside wasm_runtime_instantiate, before the exec env +// carries the vm, so a native that traps there adopts the booting one. +WasmScriptingVM* bootVmFromEnv(wasm_exec_env_t env) +{ + WasmScriptingVM* vm = vmFromEnv(env); + return vm != nullptr ? vm : WasmScriptingVMNatives::adoptBooting(env); +} + // Prototypes, descriptor PODs, and registration tables for the rive_*_v1 // namespaces come from the binding IDL (src/wasm/idl/bindings.py); the // declarations pin each implementation below to the contract signature. @@ -4366,7 +4409,12 @@ ms); // Terminates like a trap when the native returns, so the caller's // failed-op handling engages. - vm->raiseModuleError("execution exceeded timeout"); + vm->raiseBudgetExceeded(); +} + +void rtErrorImpl(WasmScriptingVM* vm, const char* message, uint32_t length) +{ + vm->raiseThrown(std::string(message, length).c_str()); } // Unfused multiply-add keeps these bit identical to the module's own f32 @@ -8582,7 +8630,10 @@ s_bootPrint = nullptr; if (m_state->instance == nullptr) { - m_lastError = std::string("module instantiate failed: ") + error; + // The runtime's exception buffer truncates what start threw. + m_lastError = std::string("module instantiate failed: ") + + (m_moduleErrorDetail.empty() ? std::string(error) + : m_moduleErrorDetail); return false; } m_state->execEnv =
diff --git a/src/wasm/web/rive_module_imports.mjs b/src/wasm/web/rive_module_imports.mjs index a45f6ee..a22e4d8 100644 --- a/src/wasm/web/rive_module_imports.mjs +++ b/src/wasm/web/rive_module_imports.mjs
@@ -41,6 +41,11 @@ }, mark_needs_update: calls.rive_rt_mark_needs_update, budget_exceeded: calls.rive_rt_budget_exceeded, + error: (message, length) => { + stackReset(); + const message_p = stageIn(message, length); + calls.rive_rt_error(message_p, length); + }, debug_enter: calls.rive_rt_debug_enter, debug_line: calls.rive_rt_debug_line, debug_leave: calls.rive_rt_debug_leave,
diff --git a/tests/unit_tests/runtime/scripting/scripting_context_test.cpp b/tests/unit_tests/runtime/scripting/scripting_context_test.cpp index 9e70caf..78dd2bd 100644 --- a/tests/unit_tests/runtime/scripting/scripting_context_test.cpp +++ b/tests/unit_tests/runtime/scripting/scripting_context_test.cpp
@@ -1993,3 +1993,12 @@ object.scriptDispose(); } + +// print joins its arguments with a tab, as Luau's own print does, so several +// values on one line read back apart rather than run together. +TEST_CASE("print separates its arguments with tabs", "[scripting]") +{ + ScriptingTest vm(R"(print("alpha", 1, true, nil, 2.5))", 0); + REQUIRE(vm.console.size() == 1); + CHECK(vm.console[0] == "alpha\t1\ttrue\tnil\t2.5"); +}