From 255ff87f3744e99937947cfa01cc45ee0957fdbd Mon Sep 17 00:00:00 2001 From: leftibot Date: Sat, 11 Apr 2026 15:45:22 -0600 Subject: [PATCH 1/5] Fix #405: push_back() on script-created vector has no effect if vector_conversion is in effect (#663) * Fix #405: push_back() on script-created vector has no effect with vector_conversion When both vector_type and vector_conversion are registered, the dispatch scoring treats all parameter mismatches equally. This causes the C++ push_back (for the converted type) to be selected over the built-in one when both have the same number of type differences. The converted function operates on a temporary copy of the vector, silently discarding the mutation. The fix deprioritizes overloads that require type conversion on the first parameter (the object/receiver), ensuring functions matching the receiver type exactly are always tried first. Co-Authored-By: Claude Opus 4.6 (1M context) * Address review: remove issue references from comments, add round-trip conversion tests Requested by @lefticus in PR #663 review. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: leftibot Co-authored-by: Claude Opus 4.6 (1M context) --- .../dispatchkit/proxy_functions.hpp | 8 +++ unittests/compiled_tests.cpp | 62 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/include/chaiscript/dispatchkit/proxy_functions.hpp b/include/chaiscript/dispatchkit/proxy_functions.hpp index fe65ee5a..002e473b 100644 --- a/include/chaiscript/dispatchkit/proxy_functions.hpp +++ b/include/chaiscript/dispatchkit/proxy_functions.hpp @@ -798,6 +798,14 @@ namespace chaiscript { ++numdiffs; } } + + // Deprioritize functions whose first parameter (object/receiver) requires + // type conversion: conversions create temporaries, so mutations on the + // converted object are silently lost. + if (plist.size() > 1 && !func->get_param_types()[1].bare_equal(plist[0].get_type_info())) { + numdiffs = plist.size(); + } + ordered_funcs.emplace_back(numdiffs, func.get()); } } diff --git a/unittests/compiled_tests.cpp b/unittests/compiled_tests.cpp index 4c7d96fa..5a3f09d1 100644 --- a/unittests/compiled_tests.cpp +++ b/unittests/compiled_tests.cpp @@ -1437,6 +1437,68 @@ TEST_CASE("Issue #421 - Switch with type_conversion does not compare destroyed o })") == 0); } +// Regression test: push_back() on script-created vector has no effect when +// vector_conversion is in effect. The bug occurs because dispatch selects +// the C++ push_back for the converted type over the built-in one, operating +// on a temporary copy of the vector. +TEST_CASE("push_back on script vector with vector_conversion") { + chaiscript::ChaiScript_Basic chai(create_chaiscript_stdlib(), create_chaiscript_parser()); + + auto m = std::make_shared(); + chaiscript::bootstrap::standard_library::vector_type>("VectorString", *m); + m->add(chaiscript::vector_conversion>()); + chai.add(m); + + // Register a C++ function that accepts the converted type, so we can + // verify that vector_conversion actually works for passing vectors + chai.add(chaiscript::fun([](const std::vector &v) -> std::string { + std::string result; + for (const auto &s : v) { + if (!result.empty()) { result += ","; } + result += s; + } + return result; + }), "join_strings"); + + // push_back on an empty script-created vector must be visible + CHECK(chai.eval( + "auto x = [];" + "x.push_back(\"Hello\");" + "x.size() == 1" + )); + + // push_back on a vector with initial elements must grow correctly + CHECK(chai.eval( + "auto y = [\"a\", \"b\"];" + "y.push_back(\"c\");" + "y.push_back(\"d\");" + "y.size() == 4" + )); + + // Verify the actual content is preserved after push_back + CHECK(chai.eval( + "auto z = [];" + "z.push_back(\"World\");" + "z[0]" + ) == "World"); + + // Round-trip: build a vector in script, push_back elements, then pass it + // to a C++ function via vector_conversion and verify the contents + CHECK(chai.eval( + "auto v = [\"one\", \"two\"];" + "v.push_back(\"three\");" + "join_strings(v)" + ) == "one,two,three"); + + // Verify conversion works on a freshly created vector too + CHECK(chai.eval( + "auto w = [];" + "w.push_back(\"hello\");" + "w.push_back(\"world\");" + "join_strings(w)" + ) == "hello,world"); +} + // Regression test for issue #607: AST_Node_Trace must be a complete type // when used in eval_error's std::vector call_stack member. // This failed to compile with C++20 on clang/libc++ when AST_Node_Trace From 340e7d4b1607c05d91b7eb81c67e3316938da8b1 Mon Sep 17 00:00:00 2001 From: leftibot Date: Sat, 11 Apr 2026 15:58:28 -0600 Subject: [PATCH 2/5] Fix #472: Emscripten Frontend (#662) * Fix #472: Add Emscripten/WebAssembly build for browser-based ChaiScript Port Rob Loach's ChaiScript.js work (https://github.com/RobLoach/ChaiScript.js) into the main repository as an Emscripten build target. Adds a GitHub Actions workflow that builds ChaiScript to WebAssembly and publishes artifacts (JS, WASM, HTML) for embedding in the official ChaiScript.com website. Includes an HTML interactive playground frontend and a native test validating the eval API surface. Co-Authored-By: Rob Loach Co-Authored-By: Claude Opus 4.6 (1M context) * Address review: publish WASM assets as release under wasm-latest tag Add a publish job to the emscripten workflow that creates a prerelease tagged wasm-latest with chaiscript.js, chaiscript.wasm, and chaiscript.html as downloadable assets. Runs only on pushes to the develop branch. The website repo can fetch these via the public GitHub Releases API on a daily cron without any cross-repo auth. Requested by @lefticus in PR #662 review. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: leftibot Co-authored-by: Rob Loach Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/emscripten.yml | 71 +++++++++ CMakeLists.txt | 4 + emscripten/CMakeLists.txt | 35 +++++ emscripten/chaiscript.html | 237 +++++++++++++++++++++++++++++ emscripten/chaiscript_em.cpp | 23 +++ emscripten/chaiscript_eval.hpp | 48 ++++++ unittests/emscripten_eval_test.cpp | 51 +++++++ 7 files changed, 469 insertions(+) create mode 100644 .github/workflows/emscripten.yml create mode 100644 emscripten/CMakeLists.txt create mode 100644 emscripten/chaiscript.html create mode 100644 emscripten/chaiscript_em.cpp create mode 100644 emscripten/chaiscript_eval.hpp create mode 100644 unittests/emscripten_eval_test.cpp diff --git a/.github/workflows/emscripten.yml b/.github/workflows/emscripten.yml new file mode 100644 index 00000000..4be8931f --- /dev/null +++ b/.github/workflows/emscripten.yml @@ -0,0 +1,71 @@ +name: Emscripten + +on: + push: + branches: [develop, main] + pull_request: + branches: [develop, main] + workflow_dispatch: + +jobs: + emscripten: + name: Emscripten WebAssembly Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Emscripten + uses: mymindstorm/setup-emsdk@v14 + + - name: Configure + run: emcmake cmake -B build-em -S emscripten -DCMAKE_BUILD_TYPE=Release + + - name: Build + run: cmake --build build-em -j + + - name: Verify artifacts + run: | + test -f build-em/chaiscript.js + test -f build-em/chaiscript.wasm + test -f build-em/chaiscript.html + echo "All expected artifacts present" + ls -lh build-em/chaiscript.* + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: chaiscript-web + path: | + build-em/chaiscript.js + build-em/chaiscript.wasm + build-em/chaiscript.html + retention-days: 90 + + publish: + name: Publish WASM Release + needs: emscripten + if: github.ref == 'refs/heads/develop' && github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: chaiscript-web + path: artifacts + + - name: Publish to wasm-latest release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Flatten: artifacts may contain build-em/ subdirectory + find artifacts -type f \( -name 'chaiscript.js' -o -name 'chaiscript.wasm' -o -name 'chaiscript.html' \) -exec cp {} . \; + + # Delete existing release if present, then recreate + gh release delete wasm-latest --repo "$GITHUB_REPOSITORY" -y 2>/dev/null || true + gh release create wasm-latest \ + --repo "$GITHUB_REPOSITORY" \ + --title "ChaiScript WASM Build (latest)" \ + --notes "Auto-published from develop branch. Built with Emscripten for browser use." \ + --prerelease \ + chaiscript.js chaiscript.wasm chaiscript.html diff --git a/CMakeLists.txt b/CMakeLists.txt index ad3e79a0..029f33da 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -423,6 +423,10 @@ if(BUILD_TESTING) target_link_libraries(multifile_test ${LIBS}) add_test(NAME MultiFile_Test COMMAND multifile_test) + add_executable(emscripten_eval_test unittests/emscripten_eval_test.cpp) + target_link_libraries(emscripten_eval_test ${LIBS}) + add_test(NAME Emscripten_Eval_Test COMMAND emscripten_eval_test) + install(TARGETS test_module RUNTIME DESTINATION bin LIBRARY DESTINATION "${CMAKE_INSTALL_LIBDIR}/chaiscript") endif() endif() diff --git a/emscripten/CMakeLists.txt b/emscripten/CMakeLists.txt new file mode 100644 index 00000000..7751631f --- /dev/null +++ b/emscripten/CMakeLists.txt @@ -0,0 +1,35 @@ +# Emscripten/WebAssembly build for ChaiScript +# Based on work by Rob Loach: https://github.com/RobLoach/ChaiScript.js +# +# Usage: +# emcmake cmake -B build-em -S emscripten +# cmake --build build-em + +cmake_minimum_required(VERSION 3.12) +project(chaiscript_em) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Emscripten-specific compiler/linker flags +add_definitions(-DCHAISCRIPT_NO_THREADS -DCHAISCRIPT_NO_DYNLOAD) + +add_executable(chaiscript chaiscript_em.cpp) +target_include_directories(chaiscript PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../include) + +# Emscripten link flags: enable embind, allow memory growth, export as ES module-compatible +target_link_options(chaiscript PRIVATE + --bind + -sALLOW_MEMORY_GROWTH=1 + -sEXPORT_ES6=0 + -sMODULARIZE=0 + -sINVOKE_RUN=0 +) + +# Copy the HTML shell to the build output directory +add_custom_command(TARGET chaiscript POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy + ${CMAKE_CURRENT_SOURCE_DIR}/chaiscript.html + ${CMAKE_CURRENT_BINARY_DIR}/chaiscript.html + COMMENT "Copying HTML frontend to build directory" +) diff --git a/emscripten/chaiscript.html b/emscripten/chaiscript.html new file mode 100644 index 00000000..29e32252 --- /dev/null +++ b/emscripten/chaiscript.html @@ -0,0 +1,237 @@ + + + + + + + ChaiScript + + + +
+

ChaiScript

+ Interactive Playground +
Loading...
+
+
+
+
Input
+ +
+
+
+
Output
+
+
+
+
+ + + Ctrl+Enter to run +
+ + + + + diff --git a/emscripten/chaiscript_em.cpp b/emscripten/chaiscript_em.cpp new file mode 100644 index 00000000..06806797 --- /dev/null +++ b/emscripten/chaiscript_em.cpp @@ -0,0 +1,23 @@ +// This file is distributed under the BSD License. +// See "license.txt" for details. +// Copyright 2019, Rob Loach (https://github.com/RobLoach/ChaiScript.js) +// Copyright 2009-2018, Jason Turner (jason@emptycrate.com) +// http://www.chaiscript.com +// +// Emscripten/WebAssembly wrapper for ChaiScript. +// Based on work by Rob Loach: https://github.com/RobLoach/ChaiScript.js + +#include "chaiscript_eval.hpp" + +#ifdef __EMSCRIPTEN__ +#include + +EMSCRIPTEN_BINDINGS(chaiscript) { + 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); +} +#endif diff --git a/emscripten/chaiscript_eval.hpp b/emscripten/chaiscript_eval.hpp new file mode 100644 index 00000000..52791ab3 --- /dev/null +++ b/emscripten/chaiscript_eval.hpp @@ -0,0 +1,48 @@ +// This file is distributed under the BSD License. +// See "license.txt" for details. +// Copyright 2019, Rob Loach (https://github.com/RobLoach/ChaiScript.js) +// Copyright 2009-2018, Jason Turner (jason@emptycrate.com) +// http://www.chaiscript.com + +// 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. + +#ifndef CHAISCRIPT_EMSCRIPTEN_EVAL_HPP_ +#define CHAISCRIPT_EMSCRIPTEN_EVAL_HPP_ + +#include +#include + +namespace detail { +inline chaiscript::ChaiScript &get_chai_instance() { + static chaiscript::ChaiScript chai; + return chai; +} +} // namespace detail + +inline void chaiscript_eval(const std::string &input) { + detail::get_chai_instance().eval(input); +} + +inline std::string chaiscript_eval_string(const std::string &input) { + return detail::get_chai_instance().eval(input); +} + +inline bool chaiscript_eval_bool(const std::string &input) { + return detail::get_chai_instance().eval(input); +} + +inline int chaiscript_eval_int(const std::string &input) { + return detail::get_chai_instance().eval(input); +} + +inline float chaiscript_eval_float(const std::string &input) { + return detail::get_chai_instance().eval(input); +} + +inline double chaiscript_eval_double(const std::string &input) { + return detail::get_chai_instance().eval(input); +} + +#endif /* CHAISCRIPT_EMSCRIPTEN_EVAL_HPP_ */ diff --git a/unittests/emscripten_eval_test.cpp b/unittests/emscripten_eval_test.cpp new file mode 100644 index 00000000..6084c9d3 --- /dev/null +++ b/unittests/emscripten_eval_test.cpp @@ -0,0 +1,51 @@ +// Test that validates the eval patterns used by the Emscripten wrapper. +// Based on work by Rob Loach (https://github.com/RobLoach/ChaiScript.js) + +#ifndef CHAISCRIPT_NO_THREADS +#define CHAISCRIPT_NO_THREADS +#endif + +#ifndef CHAISCRIPT_NO_DYNLOAD +#define CHAISCRIPT_NO_DYNLOAD +#endif + +#include +#include "../emscripten/chaiscript_eval.hpp" +#include +#include +#include + +int main() { + // Test eval (void return) - same as Emscripten eval() + chaiscript_eval("var x = 42"); + + // Test evalString - same as Emscripten evalString() + std::string s = chaiscript_eval_string("to_string(x)"); + assert(s == "42"); + + // Test evalInt - same as Emscripten evalInt() + int i = chaiscript_eval_int("1 + 2"); + assert(i == 3); + + // Test evalBool - same as Emscripten evalBool() + bool b = chaiscript_eval_bool("true"); + assert(b == true); + + b = chaiscript_eval_bool("false"); + assert(b == false); + + // Test evalFloat - same as Emscripten evalFloat() + float f = chaiscript_eval_float("1.5f"); + assert(std::abs(f - 1.5f) < 0.001f); + + // Test evalDouble - same as Emscripten evalDouble() + double d = chaiscript_eval_double("3.14"); + assert(std::abs(d - 3.14) < 0.001); + + // Test a more complex expression + chaiscript_eval("def square(n) { return n * n; }"); + int sq = chaiscript_eval_int("square(7)"); + assert(sq == 49); + + return 0; +} From 1b27d0dd15f45b4781273bc039713a8d8608cf86 Mon Sep 17 00:00:00 2001 From: leftibot Date: Sat, 11 Apr 2026 16:08:09 -0600 Subject: [PATCH 3/5] Fix #459: Fill out the set of conversion functions for all POD types (#664) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, to_ only accepted a string or the same type (identity), and () constructors only accepted Boxed_Number. This left gaps such as to_int(char), to_char(int), char(string), int(string), and to_double(char). Add two overloads to bootstrap_pod_type: to_(Boxed_Number) enables cross-type numeric conversions (e.g. to_int('A') → 65, to_char(65) → 'A'), and (string) via parse_string enables construction from strings (e.g. char("A") → 'A', int("65") → 65, double("3.5") → 3.5). Co-authored-by: leftibot Co-authored-by: Claude Opus 4.6 (1M context) --- include/chaiscript/dispatchkit/bootstrap.hpp | 2 ++ unittests/conversion_functions.chai | 33 ++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 unittests/conversion_functions.chai diff --git a/include/chaiscript/dispatchkit/bootstrap.hpp b/include/chaiscript/dispatchkit/bootstrap.hpp index ba1de5ad..d9b3e23e 100644 --- a/include/chaiscript/dispatchkit/bootstrap.hpp +++ b/include/chaiscript/dispatchkit/bootstrap.hpp @@ -115,6 +115,8 @@ namespace chaiscript::bootstrap { m.add(fun(&parse_string), "to_" + name); m.add(fun([](const T t) { return t; }), "to_" + name); + m.add(fun([](const Boxed_Number &bn) { return bn.get_as(); }), "to_" + name); + m.add(fun(&parse_string), name); } /// "clone" function for a shared_ptr type. This is used in the case diff --git a/unittests/conversion_functions.chai b/unittests/conversion_functions.chai new file mode 100644 index 00000000..4f51e101 --- /dev/null +++ b/unittests/conversion_functions.chai @@ -0,0 +1,33 @@ +// Test that all conversion functions work across char, int, and string types + +// char() constructor +assert_equal('A', char('A')) // char from char (identity via Boxed_Number) +assert_equal('A', char(65)) // char from int (via Boxed_Number) +assert_equal('A', char("A")) // char from string (new) + +// to_char() conversions +assert_equal('A', to_char('A')) // to_char from char (identity) +assert_equal('A', to_char(65)) // to_char from int (new, via Boxed_Number) +assert_equal('A', to_char("A")) // to_char from string (existing) + +// int() constructor +assert_equal(65, int('A')) // int from char (via Boxed_Number) +assert_equal(65, int(65)) // int from int (identity via Boxed_Number) +assert_equal(65, int("65")) // int from string (new) + +// to_int() conversions +assert_equal(65, to_int('A')) // to_int from char (new, via Boxed_Number) +assert_equal(65, to_int(65)) // to_int from int (identity) +assert_equal(65, to_int("65")) // to_int from string (existing) + +// to_string() conversions +assert_equal("A", to_string('A')) +assert_equal("65", to_string(65)) +assert_equal("A", to_string("A")) + +// double conversions +assert_equal(3.5, double("3.5")) // double from string (new) +assert_equal(3.5, to_double("3.5")) // to_double from string (existing) +assert_equal(65.0, to_double('A')) // to_double from char (new, via Boxed_Number) +assert_equal(65.0, to_double(65)) // to_double from int (new, via Boxed_Number) +assert_equal("3.5", to_string(3.5)) // to_string from double (existing) From 7b95ff5126f0365c101b08fa33b50ae387342484 Mon Sep 17 00:00:00 2001 From: leftibot Date: Sat, 11 Apr 2026 16:12:41 -0600 Subject: [PATCH 4/5] Fix #655: Async issues with threads outliving the chaiscript engine (#656) * Fix #655: Join async threads before engine destruction to prevent heap-use-after-free Issues #632 and #636 (PRs #651 and #653) both stem from the same root cause: async threads spawned via async() can outlive the Dispatch_Engine, accessing shared state (global objects map, type maps) after it has been destroyed. The fix moves async() registration from the stdlib module into ChaiScript_Basic, where spawned threads are tracked via Dispatch_Engine. The engine's destructor now joins all outstanding async threads before destroying shared data structures. Co-Authored-By: Claude Opus 4.6 (1M context) * Address review: follow rule of 5, explicitly default move operations Requested by @lefticus in PR #656 review. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: leftibot Co-authored-by: Claude Opus 4.6 (1M context) --- CMakeLists.txt | 3 ++ include/chaiscript/chaiscript_stdlib.hpp | 5 +- .../chaiscript/dispatchkit/dispatchkit.hpp | 43 +++++++++++++++++ .../chaiscript/language/chaiscript_engine.hpp | 20 ++++++++ unittests/async_engine_lifetime.chai | 17 +++++++ unittests/async_engine_lifetime_test.cpp | 46 +++++++++++++++++++ 6 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 unittests/async_engine_lifetime.chai create mode 100644 unittests/async_engine_lifetime_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 029f33da..7fcb4bc2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -413,6 +413,9 @@ if(BUILD_TESTING) "CHAI_USE_PATH=${CMAKE_CURRENT_SOURCE_DIR}/unittests/" "CHAI_MODULE_PATH=${CMAKE_CURRENT_BINARY_DIR}/" ) + add_executable(async_engine_lifetime_test unittests/async_engine_lifetime_test.cpp) + target_link_libraries(async_engine_lifetime_test ${LIBS}) + add_test(NAME Async_Engine_Lifetime_Test COMMAND async_engine_lifetime_test) endif() add_executable(multifile_test diff --git a/include/chaiscript/chaiscript_stdlib.hpp b/include/chaiscript/chaiscript_stdlib.hpp index 5cb4fa68..0daac71e 100644 --- a/include/chaiscript/chaiscript_stdlib.hpp +++ b/include/chaiscript/chaiscript_stdlib.hpp @@ -49,9 +49,8 @@ namespace chaiscript { #ifndef CHAISCRIPT_NO_THREADS bootstrap::standard_library::future_type>("future", *lib); - lib->add(chaiscript::fun( - [](const std::function &t_func) { return std::async(std::launch::async, t_func); }), - "async"); + // Note: async() is registered in ChaiScript_Basic::build_eval_system() + // with thread tracking to prevent heap-use-after-free on engine destruction. #endif json_wrap::library(*lib); diff --git a/include/chaiscript/dispatchkit/dispatchkit.hpp b/include/chaiscript/dispatchkit/dispatchkit.hpp index b54d42b4..95627dbc 100644 --- a/include/chaiscript/dispatchkit/dispatchkit.hpp +++ b/include/chaiscript/dispatchkit/dispatchkit.hpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -370,6 +371,28 @@ namespace chaiscript { , m_parser(parser) { } + ~Dispatch_Engine() { + join_async_threads(); + } + + Dispatch_Engine(const Dispatch_Engine &) = delete; + Dispatch_Engine &operator=(const Dispatch_Engine &) = delete; + Dispatch_Engine(Dispatch_Engine &&) = default; + Dispatch_Engine &operator=(Dispatch_Engine &&) = default; + +#ifndef CHAISCRIPT_NO_THREADS + /// Track an async thread so it can be joined during destruction + void track_async_thread(std::thread t_thread) { + chaiscript::detail::threading::unique_lock l(m_async_mutex); + // Clean up already-finished threads to avoid unbounded growth + m_async_threads.erase( + std::remove_if(m_async_threads.begin(), m_async_threads.end(), + [](std::thread &t) { return !t.joinable(); }), + m_async_threads.end()); + m_async_threads.push_back(std::move(t_thread)); + } +#endif + /// \brief casts an object while applying any Dynamic_Conversion available template decltype(auto) boxed_cast(const Boxed_Value &bv) const { @@ -1165,6 +1188,21 @@ namespace chaiscript { get_function_objects_int().insert_or_assign(t_name, std::move(new_func)); } + void join_async_threads() { +#ifndef CHAISCRIPT_NO_THREADS + std::vector threads; + { + chaiscript::detail::threading::unique_lock l(m_async_mutex); + threads = std::move(m_async_threads); + } + for (auto &t : threads) { + if (t.joinable()) { + t.join(); + } + } +#endif + } + mutable chaiscript::detail::threading::shared_mutex m_mutex; Type_Conversions m_conversions; @@ -1174,6 +1212,11 @@ namespace chaiscript { mutable std::atomic_uint_fast32_t m_method_missing_loc = {0}; State m_state; + +#ifndef CHAISCRIPT_NO_THREADS + mutable chaiscript::detail::threading::shared_mutex m_async_mutex; + std::vector m_async_threads; +#endif }; class Dispatch_State { diff --git a/include/chaiscript/language/chaiscript_engine.hpp b/include/chaiscript/language/chaiscript_engine.hpp index c9f22e58..50dfb3c7 100644 --- a/include/chaiscript/language/chaiscript_engine.hpp +++ b/include/chaiscript/language/chaiscript_engine.hpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -179,6 +180,25 @@ namespace chaiscript { }), "namespace"); m_engine.add(fun([this](const std::string &t_namespace_name) { import(t_namespace_name); }), "import"); + +#ifndef CHAISCRIPT_NO_THREADS + // Register async() with thread tracking so the engine can join all + // async threads before destroying shared state (issues #632, #636). + m_engine.add(chaiscript::fun( + [this](const std::function &t_func) { + auto promise_ptr = std::make_shared>(); + auto future = promise_ptr->get_future(); + m_engine.track_async_thread(std::thread([promise_ptr, t_func]() { + try { + promise_ptr->set_value(t_func()); + } catch (...) { + promise_ptr->set_exception(std::current_exception()); + } + })); + return future; + }), + "async"); +#endif } /// Skip BOM at the beginning of file diff --git a/unittests/async_engine_lifetime.chai b/unittests/async_engine_lifetime.chai new file mode 100644 index 00000000..94ee7066 --- /dev/null +++ b/unittests/async_engine_lifetime.chai @@ -0,0 +1,17 @@ +// Regression test for #632 and #636: Heap-use-after-free in async threads +// Async threads must complete before the engine is destroyed. + +var func = fun(){ + var ret = 0; + for (var i = 0; i < 1000; ++i) { + ret += i; + } + return ret; +} + +var fut1 = async(func); +var fut2 = async(func); + +// Wait for results to verify correctness +assert_equal(fut1.get(), 499500); +assert_equal(fut2.get(), 499500); diff --git a/unittests/async_engine_lifetime_test.cpp b/unittests/async_engine_lifetime_test.cpp new file mode 100644 index 00000000..698f64c0 --- /dev/null +++ b/unittests/async_engine_lifetime_test.cpp @@ -0,0 +1,46 @@ +// Regression test for #632 and #636: +// Heap-use-after-free when async threads outlive the ChaiScript engine. +// The engine must join all outstanding async threads before destroying shared state. + +#include + +int main() { + // Test 1: Async threads still running when engine is destroyed. + // Without the fix, this triggers heap-use-after-free under ASAN/TSAN. + for (int iter = 0; iter < 3; ++iter) { + { + chaiscript::ChaiScript chai; + try { + chai.eval(R"( + var func = fun(){ + var ret = 0; + for (var i = 0; i < 5000; ++i) { + ret += i; + } + return ret; + } + + var fut1 = async(func); + var fut2 = async(func); + )"); + // Engine destroyed here without waiting for futures. + } catch (const std::exception &) { + // Exceptions are fine + } + } + } + + // Test 2: Verify async still works correctly (results are accessible). + { + chaiscript::ChaiScript chai; + auto result = chai.eval(R"( + var f = async(fun() { return 42; }); + f.get(); + )"); + if (result != 42) { + return EXIT_FAILURE; + } + } + + return EXIT_SUCCESS; +} From 11fec25112a92f085c92f402acb8ab5e90250b8f Mon Sep 17 00:00:00 2001 From: leftibot Date: Sat, 11 Apr 2026 16:42:40 -0600 Subject: [PATCH 5/5] Fix #625: function_less_than comparator violates strict-weak ordering (#654) The function_less_than comparator used by std::stable_sort violated the strict-weak ordering requirement in two ways: (1) functions with different arities but matching overlapping parameters were treated as equivalent, breaking transitivity, and (2) the dynamic_object_type_name comparison silently fell through when one side had an empty type name. Fixed by ordering by arity when overlapping parameters match, and imposing a total order on dynamic object type names. Co-authored-by: leftibot Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Jason Turner --- .../chaiscript/dispatchkit/dispatchkit.hpp | 22 ++++++++++++++----- unittests/compiled_tests.cpp | 19 ++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/include/chaiscript/dispatchkit/dispatchkit.hpp b/include/chaiscript/dispatchkit/dispatchkit.hpp index 95627dbc..be43905d 100644 --- a/include/chaiscript/dispatchkit/dispatchkit.hpp +++ b/include/chaiscript/dispatchkit/dispatchkit.hpp @@ -1087,13 +1087,21 @@ namespace chaiscript { // overridden methods in derived classes are tried first during dispatch const auto &lhs_dotn = lhs->dynamic_object_type_name(); const auto &rhs_dotn = rhs->dynamic_object_type_name(); - if (!lhs_dotn.empty() && !rhs_dotn.empty() && lhs_dotn != rhs_dotn) { - if (dispatch::Dynamic_Object::type_matches(lhs_dotn, rhs_dotn)) { - return true; // lhs is derived from rhs, so lhs is more specific + if (lhs_dotn != rhs_dotn) { + if (!lhs_dotn.empty() && !rhs_dotn.empty()) { + if (dispatch::Dynamic_Object::type_matches(lhs_dotn, rhs_dotn)) { + return true; // lhs is derived from rhs, so lhs is more specific + } + if (dispatch::Dynamic_Object::type_matches(rhs_dotn, lhs_dotn)) { + return false; // rhs is derived from lhs, so rhs is more specific + } } - if (dispatch::Dynamic_Object::type_matches(rhs_dotn, lhs_dotn)) { - return false; // rhs is derived from lhs, so rhs is more specific + // Impose a total order on type names to maintain strict-weak ordering: + // non-empty names sort before empty names, then lexicographically + if (lhs_dotn.empty() != rhs_dotn.empty()) { + return !lhs_dotn.empty(); } + return lhs_dotn < rhs_dotn; } const auto &lhsparamtypes = lhs->get_param_types(); @@ -1143,7 +1151,9 @@ namespace chaiscript { return lt < rt; } - return false; + // When all overlapping parameters match, order by arity to maintain + // strict-weak ordering (transitivity of equivalence) + return lhssize < rhssize; } /// Implementation detail for adding a function. diff --git a/unittests/compiled_tests.cpp b/unittests/compiled_tests.cpp index 5a3f09d1..c4094da1 100644 --- a/unittests/compiled_tests.cpp +++ b/unittests/compiled_tests.cpp @@ -1437,6 +1437,25 @@ TEST_CASE("Issue #421 - Switch with type_conversion does not compare destroyed o })") == 0); } +// Issue #625: function_less_than comparator must satisfy strict-weak ordering. +// Registering overloaded functions with different arities triggered a +// std::stable_sort assertion on macOS 15.2 (hardened libc++) because the +// comparator violated transitivity of equivalence. +TEST_CASE("Issue 625: function_less_than strict-weak ordering with different arities") { + chaiscript::ChaiScript_Basic chai(create_chaiscript_stdlib(), create_chaiscript_parser()); + + // Register overloaded functions with varying arities under the same name. + // If the comparator doesn't order by arity when overlapping params match, + // std::stable_sort may exhibit undefined behavior. + CHECK_NOTHROW(chai.add(chaiscript::fun([](int x, const chaiscript::Boxed_Value &) { return x; }), "overloaded")); + CHECK_NOTHROW(chai.add(chaiscript::fun([](int x, double y) { return x + static_cast(y); }), "overloaded")); + CHECK_NOTHROW(chai.add(chaiscript::fun([](int x) { return x; }), "overloaded")); + + // Verify dispatch still works correctly + CHECK(chai.eval("overloaded(5)") == 5); + CHECK(chai.eval("overloaded(3, 2.0)") == 5); +} + // Regression test: push_back() on script-created vector has no effect when // vector_conversion is in effect. The bug occurs because dispatch selects // the C++ push_back for the converted type over the built-in one, operating