diff --git a/CMakeLists.txt b/CMakeLists.txt index e496a3f3..48d6f791 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -452,6 +452,10 @@ if(BUILD_TESTING) target_link_libraries(emscripten_exception_test ${LIBS}) add_test(NAME Emscripten_Exception_Test COMMAND emscripten_exception_test) + add_executable(emscripten_state_test unittests/emscripten_state_test.cpp) + target_link_libraries(emscripten_state_test ${LIBS}) + add_test(NAME Emscripten_State_Test COMMAND emscripten_state_test) + add_executable(threading_config_test unittests/threading_config_test.cpp) target_link_libraries(threading_config_test ${LIBS}) add_test(NAME Threading_Config_Test COMMAND threading_config_test) diff --git a/emscripten/chaiscript.html b/emscripten/chaiscript.html index 29e32252..ae7cd1a8 100644 --- a/emscripten/chaiscript.html +++ b/emscripten/chaiscript.html @@ -185,6 +185,8 @@ print("10! = " + to_string(factorial(10))) outputEl.scrollTop = outputEl.scrollHeight; } + var chaiHandle = 0; + var Module = { print: function(text) { appendOutput(text); @@ -193,6 +195,7 @@ print("10! = " + to_string(factorial(10))) appendOutput(text, 'output-error'); }, onRuntimeInitialized: function() { + chaiHandle = Module.create(); statusEl.textContent = 'Ready'; statusEl.className = 'ready'; btnRun.disabled = false; @@ -205,7 +208,7 @@ print("10! = " + to_string(factorial(10))) appendOutput('> Running...', 'output-line'); try { - Module.eval(code); + Module.eval(chaiHandle, code); } catch (e) { appendOutput('Error: ' + e.message, 'output-error'); } diff --git a/emscripten/chaiscript_em.cpp b/emscripten/chaiscript_em.cpp index 06806797..7a48c593 100644 --- a/emscripten/chaiscript_em.cpp +++ b/emscripten/chaiscript_em.cpp @@ -13,11 +13,16 @@ #include EMSCRIPTEN_BINDINGS(chaiscript) { + emscripten::function("create", &chaiscript_create); + emscripten::function("destroy", &chaiscript_destroy); emscripten::function("eval", &chaiscript_eval); emscripten::function("evalString", &chaiscript_eval_string); emscripten::function("evalBool", &chaiscript_eval_bool); emscripten::function("evalInt", &chaiscript_eval_int); emscripten::function("evalFloat", &chaiscript_eval_float); emscripten::function("evalDouble", &chaiscript_eval_double); + emscripten::function("saveState", &chaiscript_save_state); + emscripten::function("restoreState", &chaiscript_restore_state); + emscripten::function("releaseState", &chaiscript_release_state); } #endif diff --git a/emscripten/chaiscript_eval.hpp b/emscripten/chaiscript_eval.hpp index 45cf2378..ab16706e 100644 --- a/emscripten/chaiscript_eval.hpp +++ b/emscripten/chaiscript_eval.hpp @@ -7,42 +7,118 @@ // Shared eval helper functions for the ChaiScript Emscripten wrapper. // These functions provide typed evaluation of ChaiScript expressions, // used by both the Emscripten/WebAssembly build and native tests. +// +// The interface is opaque and handle-based: JS callers create one or more +// engines via chaiscript_create(), pass the resulting handle to the eval and +// state helpers, and release the engine with chaiscript_destroy() when done. +// State snapshots are themselves opaque handles produced by +// chaiscript_save_state() and consumed by chaiscript_restore_state() / +// chaiscript_release_state(). Hiding ChaiScript and ChaiScript::State behind +// integer handles keeps embind from having to manage their lifetimes and +// avoids forcing a static singleton on the C++ side. #ifndef CHAISCRIPT_EMSCRIPTEN_EVAL_HPP_ #define CHAISCRIPT_EMSCRIPTEN_EVAL_HPP_ #include +#include +#include #include +#include namespace detail { - inline chaiscript::ChaiScript &get_chai_instance() { - static chaiscript::ChaiScript chai; - return chai; + // ChaiScript::State captures globals/functions/types but not the top-level + // scripting locals (variables created by `var x = ...` at the script's + // outermost scope). The playground's reset-between-runs use case needs both, + // so the snapshot pairs the engine state with the locals map. + struct Snapshot { + chaiscript::ChaiScript::State engine_state; + std::map locals; + }; + + inline std::unordered_map> &chai_registry() { + static std::unordered_map> registry; + return registry; + } + + inline std::unordered_map &state_registry() { + static std::unordered_map registry; + return registry; + } + + inline int next_handle() { + static int handle = 0; + return ++handle; + } + + inline chaiscript::ChaiScript &get_chai(const int handle) { + return *chai_registry().at(handle); } } // namespace detail -inline void chaiscript_eval(const std::string &input) { - detail::get_chai_instance().eval(input); +// Construct a fresh ChaiScript engine and return an opaque handle. The handle +// is owned by the caller and must be released with chaiscript_destroy(). +inline int chaiscript_create() { + const int handle = detail::next_handle(); + detail::chai_registry().emplace(handle, std::make_unique()); + return handle; } -inline std::string chaiscript_eval_string(const std::string &input) { - return detail::get_chai_instance().eval(input); +// Destroy an engine handle. Unknown handles are silently ignored so JS callers +// do not need to track validity defensively. +inline void chaiscript_destroy(const int handle) { + detail::chai_registry().erase(handle); } -inline bool chaiscript_eval_bool(const std::string &input) { - return detail::get_chai_instance().eval(input); +inline void chaiscript_eval(const int handle, const std::string &input) { + detail::get_chai(handle).eval(input); } -inline int chaiscript_eval_int(const std::string &input) { - return detail::get_chai_instance().eval(input); +inline std::string chaiscript_eval_string(const int handle, const std::string &input) { + return detail::get_chai(handle).eval(input); } -inline float chaiscript_eval_float(const std::string &input) { - return detail::get_chai_instance().eval(input); +inline bool chaiscript_eval_bool(const int handle, const std::string &input) { + return detail::get_chai(handle).eval(input); } -inline double chaiscript_eval_double(const std::string &input) { - return detail::get_chai_instance().eval(input); +inline int chaiscript_eval_int(const int handle, const std::string &input) { + return detail::get_chai(handle).eval(input); +} + +inline float chaiscript_eval_float(const int handle, const std::string &input) { + return detail::get_chai(handle).eval(input); +} + +inline double chaiscript_eval_double(const int handle, const std::string &input) { + return detail::get_chai(handle).eval(input); +} + +// Snapshot the engine identified by chai_handle and return a fresh opaque +// state handle. Release it with chaiscript_release_state() when no longer +// needed. +inline int chaiscript_save_state(const int chai_handle) { + const int state_handle = detail::next_handle(); + auto &chai = detail::get_chai(chai_handle); + detail::state_registry().emplace(state_handle, detail::Snapshot{chai.get_state(), chai.get_locals()}); + return state_handle; +} + +// Restore a previously snapshotted state onto the engine identified by +// chai_handle. Unknown state handles are silently ignored. +inline void chaiscript_restore_state(const int chai_handle, const int state_handle) { + const auto it = detail::state_registry().find(state_handle); + if (it != detail::state_registry().end()) { + auto &chai = detail::get_chai(chai_handle); + chai.set_state(it->second.engine_state); + chai.set_locals(it->second.locals); + } +} + +// Release a state handle returned by chaiscript_save_state. Releasing an +// unknown handle is a no-op. +inline void chaiscript_release_state(const int state_handle) { + detail::state_registry().erase(state_handle); } #endif /* CHAISCRIPT_EMSCRIPTEN_EVAL_HPP_ */ diff --git a/unittests/emscripten_eval_test.cpp b/unittests/emscripten_eval_test.cpp index b758619a..e13c8f54 100644 --- a/unittests/emscripten_eval_test.cpp +++ b/unittests/emscripten_eval_test.cpp @@ -16,36 +16,57 @@ #include int main() { + const int chai = chaiscript_create(); + assert(chai > 0 && "create must return a positive handle"); + // Test eval (void return) - same as Emscripten eval() - chaiscript_eval("var x = 42"); + chaiscript_eval(chai, "var x = 42"); // Test evalString - same as Emscripten evalString() - [[maybe_unused]] std::string s = chaiscript_eval_string("to_string(x)"); + [[maybe_unused]] const std::string s = chaiscript_eval_string(chai, "to_string(x)"); assert(s == "42"); // Test evalInt - same as Emscripten evalInt() - [[maybe_unused]] int i = chaiscript_eval_int("1 + 2"); + [[maybe_unused]] const int i = chaiscript_eval_int(chai, "1 + 2"); assert(i == 3); // Test evalBool - same as Emscripten evalBool() - [[maybe_unused]] bool b = chaiscript_eval_bool("true"); + [[maybe_unused]] bool b = chaiscript_eval_bool(chai, "true"); assert(b == true); - b = chaiscript_eval_bool("false"); + b = chaiscript_eval_bool(chai, "false"); assert(b == false); // Test evalFloat - same as Emscripten evalFloat() - [[maybe_unused]] float f = chaiscript_eval_float("1.5f"); + [[maybe_unused]] const float f = chaiscript_eval_float(chai, "1.5f"); assert(std::abs(f - 1.5f) < 0.001f); // Test evalDouble - same as Emscripten evalDouble() - [[maybe_unused]] double d = chaiscript_eval_double("3.14"); + [[maybe_unused]] const double d = chaiscript_eval_double(chai, "3.14"); assert(std::abs(d - 3.14) < 0.001); // Test a more complex expression - chaiscript_eval("def square(n) { return n * n; }"); - [[maybe_unused]] int sq = chaiscript_eval_int("square(7)"); + chaiscript_eval(chai, "def square(n) { return n * n; }"); + [[maybe_unused]] const int sq = chaiscript_eval_int(chai, "square(7)"); assert(sq == 49); + // A second engine is fully independent of the first. + const int chai2 = chaiscript_create(); + assert(chai2 != chai && "create must mint a fresh handle each call"); + [[maybe_unused]] bool caught = false; + try { + chaiscript_eval(chai2, "x"); + } catch (const chaiscript::exception::eval_error &) { + caught = true; + } + assert(caught && "engines must not share globals"); + + chaiscript_destroy(chai2); + chaiscript_destroy(chai); + + // Destroying an unknown handle is a no-op. + chaiscript_destroy(chai); + chaiscript_destroy(99999); + return 0; } diff --git a/unittests/emscripten_exception_test.cpp b/unittests/emscripten_exception_test.cpp index 3b570a1e..0b0d90c9 100644 --- a/unittests/emscripten_exception_test.cpp +++ b/unittests/emscripten_exception_test.cpp @@ -22,12 +22,14 @@ int main() { // through the eval wrapper functions. In WASM builds without exception // support, these would abort instead of throwing. + const int chai = chaiscript_create(); + [[maybe_unused]] bool caught = false; // Test 1: eval with undefined variable should throw caught = false; try { - chaiscript_eval("this_variable_does_not_exist"); + chaiscript_eval(chai, "this_variable_does_not_exist"); } catch (const chaiscript::exception::eval_error &) { caught = true; } @@ -36,7 +38,7 @@ int main() { // Test 2: evalString with a type mismatch should throw caught = false; try { - chaiscript_eval_string("1 + 2"); + chaiscript_eval_string(chai, "1 + 2"); } catch (const chaiscript::exception::bad_boxed_cast &) { caught = true; } @@ -45,7 +47,7 @@ int main() { // Test 3: evalInt with invalid syntax should throw caught = false; try { - chaiscript_eval_int("def {}"); + chaiscript_eval_int(chai, "def {}"); } catch (const chaiscript::exception::eval_error &) { caught = true; } @@ -54,7 +56,7 @@ int main() { // Test 4: eval with throw statement should propagate exception caught = false; try { - chaiscript_eval("throw(\"user exception\")"); + chaiscript_eval(chai, "throw(\"user exception\")"); } catch (const chaiscript::Boxed_Value &) { caught = true; } catch (...) { @@ -63,11 +65,13 @@ int main() { assert(caught && "ChaiScript throw must propagate as an exception"); // Test 5: Verify normal operation still works after caught exceptions - chaiscript_eval("var post_exception_test = 100"); + chaiscript_eval(chai, "var post_exception_test = 100"); - [[maybe_unused]] const int result = chaiscript_eval_int("post_exception_test"); + [[maybe_unused]] const int result = chaiscript_eval_int(chai, "post_exception_test"); assert(result == 100 && "normal eval must work after caught exceptions"); + chaiscript_destroy(chai); + std::cout << "All emscripten exception tests passed.\n"; return 0; } diff --git a/unittests/emscripten_state_test.cpp b/unittests/emscripten_state_test.cpp new file mode 100644 index 00000000..ed3747ea --- /dev/null +++ b/unittests/emscripten_state_test.cpp @@ -0,0 +1,76 @@ +// Test that validates the save/restore/release state helpers used by the +// Emscripten wrapper. A handle-based registry is exposed so the JS playground +// can capture a pristine engine once and restore it between runs without +// reloading the WASM module. + +#ifndef CHAISCRIPT_NO_THREADS +#define CHAISCRIPT_NO_THREADS +#endif + +#ifndef CHAISCRIPT_NO_DYNLOAD +#define CHAISCRIPT_NO_DYNLOAD +#endif + +#include "../emscripten/chaiscript_eval.hpp" +#include +#include +#include + +int main() { + const int chai = chaiscript_create(); + assert(chai > 0 && "create must return a positive handle"); + + // Capture a baseline before any user code has been evaluated. + const int baseline = chaiscript_save_state(chai); + assert(baseline > 0 && "saveState must return a positive handle"); + + // Distinct calls produce distinct handles. + const int second = chaiscript_save_state(chai); + assert(second != baseline && "saveState must mint a fresh handle each call"); + chaiscript_release_state(second); + + // Define a global that did not exist in the baseline. + chaiscript_eval(chai, "var post_baseline_marker = 123"); + assert(chaiscript_eval_int(chai, "post_baseline_marker") == 123); + + // Restoring the baseline must drop the post-baseline definition: evaluating + // it should now fail with eval_error because the variable is undefined. + chaiscript_restore_state(chai, baseline); + + [[maybe_unused]] bool caught = false; + try { + chaiscript_eval(chai, "post_baseline_marker"); + } catch (const chaiscript::exception::eval_error &) { + caught = true; + } + assert(caught && "restoreState must drop globals defined after the snapshot"); + + // The engine remains usable after a restore. + chaiscript_eval(chai, "var after_restore = 7"); + assert(chaiscript_eval_int(chai, "after_restore * 6") == 42); + + // Releasing the handle is required so the registry does not grow unbounded. + chaiscript_release_state(baseline); + + // Releasing a handle that was never minted (or has already been released) is + // a no-op rather than an error. + chaiscript_release_state(baseline); + chaiscript_release_state(99999); + + // Restoring an unknown handle is a no-op: state must be unchanged. + chaiscript_restore_state(chai, 99999); + assert(chaiscript_eval_int(chai, "after_restore") == 7); + + // A snapshot taken on one engine can be restored onto a different engine, + // confirming that state is decoupled from any particular instance. + const int snapshot_with_after_restore = chaiscript_save_state(chai); + const int chai2 = chaiscript_create(); + chaiscript_restore_state(chai2, snapshot_with_after_restore); + assert(chaiscript_eval_int(chai2, "after_restore") == 7); + + chaiscript_release_state(snapshot_with_after_restore); + chaiscript_destroy(chai2); + chaiscript_destroy(chai); + + return 0; +}