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");
+}