Fix #693: Add Emscripten/embind bindings for get_state / set_state (needed for playground state reset) (#699)

* Fix #693: Add Emscripten/embind bindings for get_state/set_state

The Emscripten wrapper exported only the eval family, leaving JS consumers
with no way to snapshot or restore the singleton ChaiScript engine. The
playground in chaiscript.github.io needs that to reset between runs without
reloading the WASM module. Added handle-based wrappers that hide
ChaiScript::State behind an int registry so JS callers don't have to manage
embind object lifetimes, exported them as saveState/restoreState/releaseState,
and added a native regression test that exercises capture, restore, and
release through the same wrapper functions the WASM binding uses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address review: snapshot top-level locals alongside engine state

ChaiScript::State captures globals, functions, and types but not the
top-level scripting locals created by `var x = ...`. The previous
restoreState therefore left such variables behind, breaking the
playground reset use case and tripping the new test's assertion in
Debug builds (where assert is enabled). Pair get_state with get_locals
in the snapshot so a restore brings back a clean baseline.

Requested by @lefticus in PR #699 review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address review: make ChaiScript engine an opaque handle, drop singleton

Replace the static ChaiScript singleton in the Emscripten wrapper with a
handle-based registry symmetric to the existing State registry. JS callers
now create an engine with chaiscript_create(), pass the resulting handle to
the eval/state helpers, and release it with chaiscript_destroy(). Multiple
independent engines are now possible, and a state snapshot can be restored
onto any engine. Updated the playground HTML and the three native regression
tests to exercise the new API.

Requested by @lefticus in PR #699 review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: leftibot <leftibot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
leftibot 2026-05-02 11:04:52 -06:00 committed by GitHub
parent a4e775a024
commit 45cbec0e41
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 220 additions and 31 deletions

View File

@ -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)

View File

@ -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');
}

View File

@ -13,11 +13,16 @@
#include <emscripten/bind.h>
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

View File

@ -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 <chaiscript/chaiscript.hpp>
#include <map>
#include <memory>
#include <string>
#include <unordered_map>
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<std::string, chaiscript::Boxed_Value> locals;
};
inline std::unordered_map<int, std::unique_ptr<chaiscript::ChaiScript>> &chai_registry() {
static std::unordered_map<int, std::unique_ptr<chaiscript::ChaiScript>> registry;
return registry;
}
inline std::unordered_map<int, Snapshot> &state_registry() {
static std::unordered_map<int, Snapshot> 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<chaiscript::ChaiScript>());
return handle;
}
inline std::string chaiscript_eval_string(const std::string &input) {
return detail::get_chai_instance().eval<std::string>(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<bool>(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<int>(input);
inline std::string chaiscript_eval_string(const int handle, const std::string &input) {
return detail::get_chai(handle).eval<std::string>(input);
}
inline float chaiscript_eval_float(const std::string &input) {
return detail::get_chai_instance().eval<float>(input);
inline bool chaiscript_eval_bool(const int handle, const std::string &input) {
return detail::get_chai(handle).eval<bool>(input);
}
inline double chaiscript_eval_double(const std::string &input) {
return detail::get_chai_instance().eval<double>(input);
inline int chaiscript_eval_int(const int handle, const std::string &input) {
return detail::get_chai(handle).eval<int>(input);
}
inline float chaiscript_eval_float(const int handle, const std::string &input) {
return detail::get_chai(handle).eval<float>(input);
}
inline double chaiscript_eval_double(const int handle, const std::string &input) {
return detail::get_chai(handle).eval<double>(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_ */

View File

@ -16,36 +16,57 @@
#include <string>
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;
}

View File

@ -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;
}

View File

@ -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 <cassert>
#include <chaiscript/chaiscript.hpp>
#include <string>
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;
}