From 5cf5049549d83faddb671715bf35c7bd096b61eb Mon Sep 17 00:00:00 2001 From: leftibot Date: Thu, 30 Apr 2026 16:03:20 -0600 Subject: [PATCH] Fix #633: Bound ChaiScript call stack to prevent native stack overflow Recursive user-defined operators (e.g. a `string::/=` whose body calls itself through string interpolation) drove the AST evaluator into unbounded native recursion and crashed the host process with SIGSEGV. The dispatcher now refuses to enter a new function frame once `Stack_Holder::call_depth` reaches `chaiscript::max_call_depth` (default 256, overridable via the `CHAISCRIPT_MAX_CALL_DEPTH` macro) and throws the new `chaiscript::exception::stack_overflow_error` instead, letting both ChaiScript-level `try`/`catch` and C++ hosts recover from runaway recursion. Co-Authored-By: Claude Opus 4.7 (1M context) --- include/chaiscript/chaiscript_defines.hpp | 11 ++++++ .../chaiscript/dispatchkit/dispatchkit.hpp | 17 ++++++++ unittests/recursion_depth_protection.chai | 39 +++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 unittests/recursion_depth_protection.chai diff --git a/include/chaiscript/chaiscript_defines.hpp b/include/chaiscript/chaiscript_defines.hpp index 530fb419..a5d8c6df 100644 --- a/include/chaiscript/chaiscript_defines.hpp +++ b/include/chaiscript/chaiscript_defines.hpp @@ -75,6 +75,14 @@ static_assert(_MSC_FULL_VER >= 190024210, "Visual C++ 2015 Update 3 or later req #define CHAISCRIPT_DEBUG false #endif +// Upper bound on the depth of nested ChaiScript function calls. Hitting it +// causes the dispatcher to throw chaiscript::exception::stack_overflow_error +// instead of letting the native call stack overflow. Defining the macro on +// the command line overrides the default. +#ifndef CHAISCRIPT_MAX_CALL_DEPTH +#define CHAISCRIPT_MAX_CALL_DEPTH 256 +#endif + #include #include #include @@ -88,6 +96,9 @@ namespace chaiscript { constexpr static const char *compiler_name = CHAISCRIPT_COMPILER_NAME; constexpr static const bool debug_build = CHAISCRIPT_DEBUG; + constexpr static const int max_call_depth = CHAISCRIPT_MAX_CALL_DEPTH; + static_assert(max_call_depth > 0, "CHAISCRIPT_MAX_CALL_DEPTH must be a positive integer"); + template inline std::shared_ptr make_shared(Arg &&...arg) { #ifdef CHAISCRIPT_USE_STD_MAKE_SHARED diff --git a/include/chaiscript/dispatchkit/dispatchkit.hpp b/include/chaiscript/dispatchkit/dispatchkit.hpp index 2d1eff7e..f38147c0 100644 --- a/include/chaiscript/dispatchkit/dispatchkit.hpp +++ b/include/chaiscript/dispatchkit/dispatchkit.hpp @@ -121,6 +121,19 @@ namespace chaiscript { global_non_const(const global_non_const &) = default; ~global_non_const() noexcept override = default; }; + + /// Exception thrown when the ChaiScript call stack exceeds + /// chaiscript::max_call_depth, signalling runaway recursion before the + /// native stack can overflow. + class stack_overflow_error : public std::runtime_error { + public: + stack_overflow_error() noexcept + : std::runtime_error("Maximum call stack depth exceeded") { + } + + stack_overflow_error(const stack_overflow_error &) = default; + ~stack_overflow_error() noexcept override = default; + }; } // namespace exception /// \brief Holds a collection of ChaiScript settings which can be applied to the ChaiScript runtime. @@ -1010,6 +1023,10 @@ namespace chaiscript { void save_function_params(const Function_Params &t_params) { save_function_params(*m_stack_holder, t_params); } void new_function_call(Stack_Holder &t_s, Type_Conversions::Conversion_Saves &t_saves) { + if (t_s.call_depth >= max_call_depth) { + throw chaiscript::exception::stack_overflow_error{}; + } + if (t_s.call_depth == 0) { m_conversions.enable_conversion_saves(t_saves, true); } diff --git a/unittests/recursion_depth_protection.chai b/unittests/recursion_depth_protection.chai new file mode 100644 index 00000000..082854ff --- /dev/null +++ b/unittests/recursion_depth_protection.chai @@ -0,0 +1,39 @@ +// Regression test for issue #633: Stack-overflow due to infinite recursion +// in user-defined operator (string interpolation). +// +// Before the fix the recursive `/=` invocation triggered unbounded native +// recursion in the evaluator and crashed the host process with a SIGSEGV. +// The engine now bounds call_depth and throws a catchable exception +// instead of letting the native stack overflow. + +def string::`/=`(double d) { + this = "${this/= 2}/=${d}"; + return this; +} + +var s = "o World" +var caught = false +var message = "" + +try { + s /= 2 + // unreachable: the recursive operator must abort with an exception + assert_true(false) +} catch (e) { + caught = true + message = e.what() +} + +assert_true(caught) + +// The reported error must mention the call-stack overflow so users can +// distinguish it from an arbitrary script-level error. +assert_true(find(message, "call stack") != -1) + +// A bounded recursion that stays below the limit must keep working. +def count_down(n) { + if (n <= 0) { return 0 } + return count_down(n - 1) + 1 +} + +assert_equal(50, count_down(50))