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>
This commit is contained in:
leftibot 2026-05-02 10:50:17 -06:00
parent 34a95a28a0
commit 19b54e1d53
6 changed files with 144 additions and 72 deletions

View File

@ -185,6 +185,8 @@ print("10! = " + to_string(factorial(10)))
outputEl.scrollTop = outputEl.scrollHeight; outputEl.scrollTop = outputEl.scrollHeight;
} }
var chaiHandle = 0;
var Module = { var Module = {
print: function(text) { print: function(text) {
appendOutput(text); appendOutput(text);
@ -193,6 +195,7 @@ print("10! = " + to_string(factorial(10)))
appendOutput(text, 'output-error'); appendOutput(text, 'output-error');
}, },
onRuntimeInitialized: function() { onRuntimeInitialized: function() {
chaiHandle = Module.create();
statusEl.textContent = 'Ready'; statusEl.textContent = 'Ready';
statusEl.className = 'ready'; statusEl.className = 'ready';
btnRun.disabled = false; btnRun.disabled = false;
@ -205,7 +208,7 @@ print("10! = " + to_string(factorial(10)))
appendOutput('> Running...', 'output-line'); appendOutput('> Running...', 'output-line');
try { try {
Module.eval(code); Module.eval(chaiHandle, code);
} catch (e) { } catch (e) {
appendOutput('Error: ' + e.message, 'output-error'); appendOutput('Error: ' + e.message, 'output-error');
} }

View File

@ -13,6 +13,8 @@
#include <emscripten/bind.h> #include <emscripten/bind.h>
EMSCRIPTEN_BINDINGS(chaiscript) { EMSCRIPTEN_BINDINGS(chaiscript) {
emscripten::function("create", &chaiscript_create);
emscripten::function("destroy", &chaiscript_destroy);
emscripten::function("eval", &chaiscript_eval); emscripten::function("eval", &chaiscript_eval);
emscripten::function("evalString", &chaiscript_eval_string); emscripten::function("evalString", &chaiscript_eval_string);
emscripten::function("evalBool", &chaiscript_eval_bool); emscripten::function("evalBool", &chaiscript_eval_bool);

View File

@ -7,21 +7,26 @@
// Shared eval helper functions for the ChaiScript Emscripten wrapper. // Shared eval helper functions for the ChaiScript Emscripten wrapper.
// These functions provide typed evaluation of ChaiScript expressions, // These functions provide typed evaluation of ChaiScript expressions,
// used by both the Emscripten/WebAssembly build and native tests. // 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_ #ifndef CHAISCRIPT_EMSCRIPTEN_EVAL_HPP_
#define CHAISCRIPT_EMSCRIPTEN_EVAL_HPP_ #define CHAISCRIPT_EMSCRIPTEN_EVAL_HPP_
#include <chaiscript/chaiscript.hpp> #include <chaiscript/chaiscript.hpp>
#include <map> #include <map>
#include <memory>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
namespace detail { 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 // ChaiScript::State captures globals/functions/types but not the top-level
// scripting locals (variables created by `var x = ...` at the script's // scripting locals (variables created by `var x = ...` at the script's
// outermost scope). The playground's reset-between-runs use case needs both, // outermost scope). The playground's reset-between-runs use case needs both,
@ -31,66 +36,89 @@ namespace detail {
std::map<std::string, chaiscript::Boxed_Value> locals; 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() { inline std::unordered_map<int, Snapshot> &state_registry() {
static std::unordered_map<int, Snapshot> registry; static std::unordered_map<int, Snapshot> registry;
return registry; return registry;
} }
inline int next_state_handle() { inline int next_handle() {
static int handle = 0; static int handle = 0;
return ++handle; return ++handle;
} }
inline chaiscript::ChaiScript &get_chai(const int handle) {
return *chai_registry().at(handle);
}
} // namespace detail } // namespace detail
inline void chaiscript_eval(const std::string &input) { // Construct a fresh ChaiScript engine and return an opaque handle. The handle
detail::get_chai_instance().eval(input); // is owned by the caller and must be released with chaiscript_destroy().
} inline int chaiscript_create() {
const int handle = detail::next_handle();
inline std::string chaiscript_eval_string(const std::string &input) { detail::chai_registry().emplace(handle, std::make_unique<chaiscript::ChaiScript>());
return detail::get_chai_instance().eval<std::string>(input);
}
inline bool chaiscript_eval_bool(const std::string &input) {
return detail::get_chai_instance().eval<bool>(input);
}
inline int chaiscript_eval_int(const std::string &input) {
return detail::get_chai_instance().eval<int>(input);
}
inline float chaiscript_eval_float(const std::string &input) {
return detail::get_chai_instance().eval<float>(input);
}
inline double chaiscript_eval_double(const std::string &input) {
return detail::get_chai_instance().eval<double>(input);
}
// Snapshot the current engine state and return an opaque handle. The handle
// is owned by the caller; release it with chaiscript_release_state when it is
// no longer needed.
inline int chaiscript_save_state() {
const int handle = detail::next_state_handle();
auto &chai = detail::get_chai_instance();
detail::state_registry().emplace(handle, detail::Snapshot{chai.get_state(), chai.get_locals()});
return handle; return handle;
} }
// Restore a previously snapshotted state. Unknown handles are silently // Destroy an engine handle. Unknown handles are silently ignored so JS callers
// ignored so JS callers do not need to track validity defensively. // do not need to track validity defensively.
inline void chaiscript_restore_state(const int handle) { inline void chaiscript_destroy(const int handle) {
const auto it = detail::state_registry().find(handle); detail::chai_registry().erase(handle);
}
inline void chaiscript_eval(const int handle, const std::string &input) {
detail::get_chai(handle).eval(input);
}
inline std::string chaiscript_eval_string(const int handle, const std::string &input) {
return detail::get_chai(handle).eval<std::string>(input);
}
inline bool chaiscript_eval_bool(const int handle, const std::string &input) {
return detail::get_chai(handle).eval<bool>(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()) { if (it != detail::state_registry().end()) {
auto &chai = detail::get_chai_instance(); auto &chai = detail::get_chai(chai_handle);
chai.set_state(it->second.engine_state); chai.set_state(it->second.engine_state);
chai.set_locals(it->second.locals); chai.set_locals(it->second.locals);
} }
} }
// Release a handle returned by chaiscript_save_state. Releasing an unknown // Release a state handle returned by chaiscript_save_state. Releasing an
// handle is a no-op. // unknown handle is a no-op.
inline void chaiscript_release_state(const int handle) { inline void chaiscript_release_state(const int state_handle) {
detail::state_registry().erase(handle); detail::state_registry().erase(state_handle);
} }
#endif /* CHAISCRIPT_EMSCRIPTEN_EVAL_HPP_ */ #endif /* CHAISCRIPT_EMSCRIPTEN_EVAL_HPP_ */

View File

@ -16,36 +16,57 @@
#include <string> #include <string>
int main() { int main() {
const int chai = chaiscript_create();
assert(chai > 0 && "create must return a positive handle");
// Test eval (void return) - same as Emscripten eval() // 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() // 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"); assert(s == "42");
// Test evalInt - same as Emscripten evalInt() // 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); assert(i == 3);
// Test evalBool - same as Emscripten evalBool() // 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); assert(b == true);
b = chaiscript_eval_bool("false"); b = chaiscript_eval_bool(chai, "false");
assert(b == false); assert(b == false);
// Test evalFloat - same as Emscripten evalFloat() // 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); assert(std::abs(f - 1.5f) < 0.001f);
// Test evalDouble - same as Emscripten evalDouble() // 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); assert(std::abs(d - 3.14) < 0.001);
// Test a more complex expression // Test a more complex expression
chaiscript_eval("def square(n) { return n * n; }"); chaiscript_eval(chai, "def square(n) { return n * n; }");
[[maybe_unused]] int sq = chaiscript_eval_int("square(7)"); [[maybe_unused]] const int sq = chaiscript_eval_int(chai, "square(7)");
assert(sq == 49); 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; return 0;
} }

View File

@ -22,12 +22,14 @@ int main() {
// through the eval wrapper functions. In WASM builds without exception // through the eval wrapper functions. In WASM builds without exception
// support, these would abort instead of throwing. // support, these would abort instead of throwing.
const int chai = chaiscript_create();
[[maybe_unused]] bool caught = false; [[maybe_unused]] bool caught = false;
// Test 1: eval with undefined variable should throw // Test 1: eval with undefined variable should throw
caught = false; caught = false;
try { try {
chaiscript_eval("this_variable_does_not_exist"); chaiscript_eval(chai, "this_variable_does_not_exist");
} catch (const chaiscript::exception::eval_error &) { } catch (const chaiscript::exception::eval_error &) {
caught = true; caught = true;
} }
@ -36,7 +38,7 @@ int main() {
// Test 2: evalString with a type mismatch should throw // Test 2: evalString with a type mismatch should throw
caught = false; caught = false;
try { try {
chaiscript_eval_string("1 + 2"); chaiscript_eval_string(chai, "1 + 2");
} catch (const chaiscript::exception::bad_boxed_cast &) { } catch (const chaiscript::exception::bad_boxed_cast &) {
caught = true; caught = true;
} }
@ -45,7 +47,7 @@ int main() {
// Test 3: evalInt with invalid syntax should throw // Test 3: evalInt with invalid syntax should throw
caught = false; caught = false;
try { try {
chaiscript_eval_int("def {}"); chaiscript_eval_int(chai, "def {}");
} catch (const chaiscript::exception::eval_error &) { } catch (const chaiscript::exception::eval_error &) {
caught = true; caught = true;
} }
@ -54,7 +56,7 @@ int main() {
// Test 4: eval with throw statement should propagate exception // Test 4: eval with throw statement should propagate exception
caught = false; caught = false;
try { try {
chaiscript_eval("throw(\"user exception\")"); chaiscript_eval(chai, "throw(\"user exception\")");
} catch (const chaiscript::Boxed_Value &) { } catch (const chaiscript::Boxed_Value &) {
caught = true; caught = true;
} catch (...) { } catch (...) {
@ -63,11 +65,13 @@ int main() {
assert(caught && "ChaiScript throw must propagate as an exception"); assert(caught && "ChaiScript throw must propagate as an exception");
// Test 5: Verify normal operation still works after caught exceptions // 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"); assert(result == 100 && "normal eval must work after caught exceptions");
chaiscript_destroy(chai);
std::cout << "All emscripten exception tests passed.\n"; std::cout << "All emscripten exception tests passed.\n";
return 0; return 0;
} }

View File

@ -17,34 +17,37 @@
#include <string> #include <string>
int main() { 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. // Capture a baseline before any user code has been evaluated.
const int baseline = chaiscript_save_state(); const int baseline = chaiscript_save_state(chai);
assert(baseline > 0 && "saveState must return a positive handle"); assert(baseline > 0 && "saveState must return a positive handle");
// Distinct calls produce distinct handles. // Distinct calls produce distinct handles.
const int second = chaiscript_save_state(); const int second = chaiscript_save_state(chai);
assert(second != baseline && "saveState must mint a fresh handle each call"); assert(second != baseline && "saveState must mint a fresh handle each call");
chaiscript_release_state(second); chaiscript_release_state(second);
// Define a global that did not exist in the baseline. // Define a global that did not exist in the baseline.
chaiscript_eval("var post_baseline_marker = 123"); chaiscript_eval(chai, "var post_baseline_marker = 123");
assert(chaiscript_eval_int("post_baseline_marker") == 123); assert(chaiscript_eval_int(chai, "post_baseline_marker") == 123);
// Restoring the baseline must drop the post-baseline definition: evaluating // Restoring the baseline must drop the post-baseline definition: evaluating
// it should now fail with eval_error because the variable is undefined. // it should now fail with eval_error because the variable is undefined.
chaiscript_restore_state(baseline); chaiscript_restore_state(chai, baseline);
[[maybe_unused]] bool caught = false; [[maybe_unused]] bool caught = false;
try { try {
chaiscript_eval("post_baseline_marker"); chaiscript_eval(chai, "post_baseline_marker");
} catch (const chaiscript::exception::eval_error &) { } catch (const chaiscript::exception::eval_error &) {
caught = true; caught = true;
} }
assert(caught && "restoreState must drop globals defined after the snapshot"); assert(caught && "restoreState must drop globals defined after the snapshot");
// The engine remains usable after a restore. // The engine remains usable after a restore.
chaiscript_eval("var after_restore = 7"); chaiscript_eval(chai, "var after_restore = 7");
assert(chaiscript_eval_int("after_restore * 6") == 42); assert(chaiscript_eval_int(chai, "after_restore * 6") == 42);
// Releasing the handle is required so the registry does not grow unbounded. // Releasing the handle is required so the registry does not grow unbounded.
chaiscript_release_state(baseline); chaiscript_release_state(baseline);
@ -55,8 +58,19 @@ int main() {
chaiscript_release_state(99999); chaiscript_release_state(99999);
// Restoring an unknown handle is a no-op: state must be unchanged. // Restoring an unknown handle is a no-op: state must be unchanged.
chaiscript_restore_state(99999); chaiscript_restore_state(chai, 99999);
assert(chaiscript_eval_int("after_restore") == 7); 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; return 0;
} }