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) <noreply@anthropic.com>
This commit is contained in:
leftibot 2026-04-30 16:03:20 -06:00
parent d3c94e4451
commit 5cf5049549
3 changed files with 67 additions and 0 deletions

View File

@ -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 <cmath>
#include <memory>
#include <string>
@ -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<typename B, typename D, typename... Arg>
inline std::shared_ptr<B> make_shared(Arg &&...arg) {
#ifdef CHAISCRIPT_USE_STD_MAKE_SHARED

View File

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

View File

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