From d39f0362f3809a1449576357fcd3ae0b346a076d Mon Sep 17 00:00:00 2001 From: Kevin Newton Date: Sun, 1 Nov 2026 20:40:45 -0500 Subject: [PATCH] Stack budget Provide a stack budget option, which the parser will now respect as its limit of how much further it can recurse. In theory this should not be necessary to set, because the maximum depth should take care of it. However, on systems with limited stack size or situations with limited stack size (like within a thread or fiber) it may be necessary to further limit the stack size. Note that this isn't actually the final state that we want to be in for this. We actually want to spawn a new thread when we hit this point. But this is a stop-gap until we get that functionality. Note that this API is C only. This is not meant to be exposed to the end user through one of the bindings. It is meant to be a low- level option that we will set ourselves. --- CHANGELOG.md | 4 +++ include/prism/compiler/stack.h | 54 ++++++++++++++++++++++++++++++++ include/prism/internal/options.h | 9 ++++++ include/prism/internal/parser.h | 29 +++++++++++++++++ include/prism/options.h | 18 +++++++++++ prism.gemspec | 1 + src/options.c | 10 ++++++ src/prism.c | 10 ++++-- src/regexp.c | 16 ++++++++-- 9 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 include/prism/compiler/stack.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 91f698a3af..7bd198edd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ## [Unreleased] +### Added + +- `pm_options_stack_budget_set` was added to the C API. It sets the number of bytes of C stack the parser may use before reporting that the source is nested too deeply, instead of overflowing the stack. `SIZE_MAX` means no budget, which is the default. The nesting depth limit alone assumes a stack of roughly 8MB, so embedders that parse on a smaller stack, such as a thread's or a fiber's, should pass a budget. + ### Changed - `Prism::Translation::Parser` now returns a `nil` AST whenever an error is reported and errors are not fatal, matching the parser gem. diff --git a/include/prism/compiler/stack.h b/include/prism/compiler/stack.h new file mode 100644 index 0000000000..890cf93e3b --- /dev/null +++ b/include/prism/compiler/stack.h @@ -0,0 +1,54 @@ +/** + * @file compiler/stack.h + */ +#ifndef PRISM_COMPILER_STACK_H +#define PRISM_COMPILER_STACK_H + +#include "prism/compiler/inline.h" + +#include + +#if defined(__has_builtin) + #if __has_builtin(__builtin_stack_address) + #define PRISM_HAS_BUILTIN_STACK_ADDRESS + #endif +#endif + +#if defined(_MSC_VER) + #include +#endif + +/** + * Returns the current position of the C stack, for measuring how much of it has + * been consumed since an earlier call. The stack pointer is read directly where + * the compiler or architecture allows it. Unlike __builtin_frame_address, that + * does not force the calling function to set up a frame pointer, which costs a + * register on targets that omit frame pointers by default (x86-64 at -O2), and + * unlike the address of a local it does not add a slot to the frame. + */ +static PRISM_INLINE uintptr_t +pm_stack_address(void) { +#if defined(PRISM_HAS_BUILTIN_STACK_ADDRESS) + return (uintptr_t) __builtin_stack_address(); +#elif defined(_MSC_VER) && defined(_M_ARM64) + return (uintptr_t) __getReg(31); +#elif defined(_MSC_VER) + return (uintptr_t) _AddressOfReturnAddress(); +#elif defined(__GNUC__) && defined(__x86_64__) + uintptr_t sp; + __asm__ ("{movq %%rsp, %0|mov %0, rsp}" : "=r" (sp)); + return sp; +#elif defined(__GNUC__) && defined(__aarch64__) + /* The register is 64 bits even where pointers are 32 (arm64_32). */ + uint64_t sp; + __asm__ ("mov %0, sp" : "=r" (sp)); + return (uintptr_t) sp; +#elif defined(__GNUC__) + return (uintptr_t) __builtin_frame_address(0); +#else + volatile char marker = 0; + return (uintptr_t) ▮ +#endif +} + +#endif diff --git a/include/prism/internal/options.h b/include/prism/internal/options.h index 7a8e72907e..0c98dba849 100644 --- a/include/prism/internal/options.h +++ b/include/prism/internal/options.h @@ -75,6 +75,15 @@ struct pm_options_t { */ pm_string_t encoding; + /* + * The number of bytes of C stack that the parser may use, measured from + * where pm_parse is called. When the recursive descent goes past it, the + * parser reports that the input is nested too deeply instead of + * overflowing the stack. SIZE_MAX means no budget was given, in which case + * only PRISM_DEPTH_MAXIMUM bounds the recursion. + */ + size_t stack_budget; + /* The number of scopes surrounding the code that is being parsed. */ size_t scopes_count; diff --git a/include/prism/internal/parser.h b/include/prism/internal/parser.h index 7e47127b5b..60ae79d457 100644 --- a/include/prism/internal/parser.h +++ b/include/prism/internal/parser.h @@ -2,6 +2,8 @@ #define PRISM_INTERNAL_PARSER_H #include "prism/compiler/accel.h" +#include "prism/compiler/inline.h" +#include "prism/compiler/stack.h" #include "prism/internal/arena.h" #include "prism/internal/comments.h" @@ -879,6 +881,19 @@ struct pm_parser_t { */ pm_node_list_t *current_block_exits; + /* + * The address of the C stack when pm_parse was entered. The stack budget + * is measured as the distance from this address. + */ + uintptr_t stack_base; + + /* + * The number of bytes of C stack below stack_base that the recursive + * descent may use before it reports that the input is nested too deeply. + * SIZE_MAX when no budget was given, so the check never fires. + */ + size_t stack_budget; + /* The version of prism that we should use to parse. */ pm_options_version_t version; @@ -1012,4 +1027,18 @@ void pm_parser_cleanup(pm_parser_t *parser); */ bool pm_keyword_p(const uint8_t *start, const uint8_t *end); +/* + * Returns true if parsing has used more C stack since pm_parse was entered than + * the stack budget allows. Every recursion that runs inside pm_parse checks + * this alongside its own depth limit, so that the budget bounds all of them. + * The distance is taken without assuming which direction the stack grows. With + * no budget the limit is SIZE_MAX, so this never fires. + */ +static PRISM_INLINE bool +pm_parser_stack_exhausted(const pm_parser_t *parser) { + uintptr_t current = pm_stack_address(); + uintptr_t used = current < parser->stack_base ? parser->stack_base - current : current - parser->stack_base; + return used > parser->stack_budget; +} + #endif diff --git a/include/prism/options.h b/include/prism/options.h index 1b73c7b3e8..19f520854c 100644 --- a/include/prism/options.h +++ b/include/prism/options.h @@ -288,6 +288,24 @@ PRISM_EXPORTED_FUNCTION uint8_t pm_options_raise_error(const pm_options_t *optio */ PRISM_EXPORTED_FUNCTION void pm_options_raise_error_set(pm_options_t *options, uint8_t raise_error) PRISM_NONNULL(1); +/** + * Set the stack_budget option on the given options struct. This is the number + * of bytes of C stack that the parser may use, measured from where pm_parse is + * called. Deeply nested input that would go past it is reported as nested too + * deeply instead of overflowing the stack. Callers should pass less than the + * stack they actually have left, since the parser checks the budget between + * frames rather than inside every one, and anything that walks the tree + * afterward recurses on the same stack. A budget of 0 leaves no room for any + * expression, so every one is reported as nested too deeply. A budget of + * SIZE_MAX (the default) disables the check, leaving PRISM_DEPTH_MAXIMUM as the + * only bound, which assumes a stack of roughly 8MB. + * + * @param options The options struct to set the stack_budget value on. + * @param stack_budget The stack_budget value to set, in bytes, or SIZE_MAX for + * no budget. + */ +PRISM_EXPORTED_FUNCTION void pm_options_stack_budget_set(pm_options_t *options, size_t stack_budget) PRISM_NONNULL(1); + /** * Allocate and zero out the scopes array on the given options struct. * diff --git a/prism.gemspec b/prism.gemspec index b63078626f..4a46d66cbd 100644 --- a/prism.gemspec +++ b/prism.gemspec @@ -65,6 +65,7 @@ Gem::Specification.new do |spec| "include/prism/compiler/lifetimebound.hpp", "include/prism/compiler/nodiscard.h", "include/prism/compiler/nonnull.h", + "include/prism/compiler/stack.h", "include/prism/compiler/unused.h", "include/prism/internal/allocator.h", "include/prism/internal/allocator_debug.h", diff --git a/src/options.c b/src/options.c index 4bd3f03b40..1224bc78e0 100644 --- a/src/options.c +++ b/src/options.c @@ -20,6 +20,7 @@ pm_options_new(void) { if (options == NULL) PRISM_OOM(); options->line = 1; + options->stack_budget = SIZE_MAX; return options; } @@ -268,6 +269,14 @@ void pm_options_raise_error_set(pm_options_t *options, uint8_t raise_error) { options->raise_error = raise_error; } +/** + * Set the stack_budget option on the given options struct. + */ +void +pm_options_stack_budget_set(pm_options_t *options, size_t stack_budget) { + options->stack_budget = stack_budget; +} + // For some reason, GCC analyzer thinks we're leaking allocated scopes and // locals here, even though we definitely aren't. This is a false positive. // Ideally we wouldn't need to suppress this. @@ -384,6 +393,7 @@ pm_options_read_s32(const char *data) { void pm_options_read(pm_options_t *options, const char *data) { options->line = 1; // default + options->stack_budget = SIZE_MAX; // default if (data == NULL) return; uint32_t filepath_length = pm_options_read_u32(data); diff --git a/src/prism.c b/src/prism.c index 61f924858c..865f3cd932 100644 --- a/src/prism.c +++ b/src/prism.c @@ -17849,7 +17849,7 @@ static pm_node_t * parse_pattern(pm_parser_t *parser, pm_constant_id_set_t *captures, uint8_t flags, pm_diagnostic_id_t diag_id, uint16_t depth) { /* Nested patterns recurse back into parse_pattern without passing through * parse_expression, so this cycle needs its own depth check. */ - if (PRISM_UNLIKELY(depth >= PRISM_DEPTH_MAXIMUM)) { + if (PRISM_UNLIKELY(depth >= PRISM_DEPTH_MAXIMUM || pm_parser_stack_exhausted(parser))) { pm_parser_err_current(parser, PM_ERR_NESTING_TOO_DEEP); return UP(pm_error_recovery_node_create(parser, PM_TOKEN_START(parser, &parser->current), PM_TOKEN_LENGTH(&parser->current))); } @@ -22818,7 +22818,7 @@ parse_expression_terminator(pm_parser_t *parser, pm_node_t *node) { */ static pm_node_t * parse_expression(pm_parser_t *parser, pm_binding_power_t binding_power, uint8_t flags, pm_diagnostic_id_t diag_id, uint16_t depth) { - if (PRISM_UNLIKELY(depth >= PRISM_DEPTH_MAXIMUM)) { + if (PRISM_UNLIKELY(depth >= PRISM_DEPTH_MAXIMUM || pm_parser_stack_exhausted(parser))) { pm_parser_err_current(parser, PM_ERR_NESTING_TOO_DEEP); return UP(pm_error_recovery_node_create(parser, PM_TOKEN_START(parser, &parser->current), PM_TOKEN_LENGTH(&parser->current))); } @@ -23240,6 +23240,8 @@ pm_parser_init(pm_arena_t *arena, pm_parser_t *parser, const uint8_t *source, si .pattern_matching_newlines = false, .in_keyword_arg = false, .current_block_exits = NULL, + .stack_base = 0, + .stack_budget = SIZE_MAX, .semantic_token_seen = false, .frozen_string_literal = PM_OPTIONS_FROZEN_STRING_LITERAL_UNSET, .warn_mismatched_indentation = true @@ -23303,6 +23305,9 @@ pm_parser_init(pm_arena_t *arena, pm_parser_t *parser, const uint8_t *source, si // attach_comments parser->attach_comments = options->attach_comments; + // stack_budget + parser->stack_budget = options->stack_budget; + // scopes option parser->parsing_eval = options->scopes_count > 0; if (parser->parsing_eval) parser->warn_mismatched_indentation = false; @@ -23649,6 +23654,7 @@ pm_parse_continuable(pm_parser_t *parser) { */ pm_node_t * pm_parse(pm_parser_t *parser) { + parser->stack_base = pm_stack_address(); pm_node_t *node = parse_program(parser); pm_parse_continuable(parser); if (parser->attach_comments) pm_comments_attach(parser, node); diff --git a/src/regexp.c b/src/regexp.c index 5670b0ed52..013bb23760 100644 --- a/src/regexp.c +++ b/src/regexp.c @@ -710,11 +710,11 @@ pm_regexp_parse_hex_escape(pm_regexp_parser_t *parser) { * \p/\P (property), \x (hex), and other single-character escapes. Also * tracks encoding state for \M-x and \C-\M-x escapes. The meta and control * forms can wrap another escape, so this recurses and is bounded by the same - * depth limit as groups and character sets. + * depth limit and stack budget as groups and character sets. */ static bool pm_regexp_parse_backslash_escape(pm_regexp_parser_t *parser, uint16_t depth) { - if (depth >= PM_REGEXP_PARSE_DEPTH_MAX) { + if (depth >= PM_REGEXP_PARSE_DEPTH_MAX || pm_parser_stack_exhausted(parser->parser)) { pm_regexp_parse_error(parser, parser->start, parser->end, "parse depth limit over"); return false; } @@ -846,7 +846,7 @@ pm_regexp_parse_character_set(pm_regexp_parser_t *parser, uint16_t depth) { */ static bool pm_regexp_parse_lbracket(pm_regexp_parser_t *parser, uint16_t depth) { - if (depth >= PM_REGEXP_PARSE_DEPTH_MAX) { + if (depth >= PM_REGEXP_PARSE_DEPTH_MAX || pm_parser_stack_exhausted(parser->parser)) { pm_regexp_parse_error(parser, parser->start, parser->end, "parse depth limit over"); return false; } @@ -1005,6 +1005,16 @@ pm_regexp_options_state(pm_regexp_options_t *options, uint8_t key) { */ static bool pm_regexp_parse_group(pm_regexp_parser_t *parser, uint16_t depth) { + /* Every level of nested groups passes through here, so this bounds the + * group recursion by the stack budget. The check lives here rather than + * beside the depth check in pm_regexp_parse_expression because there it + * stops that function from being inlined, which adds a frame to every + * level of nesting. */ + if (pm_parser_stack_exhausted(parser->parser)) { + pm_regexp_parse_error(parser, parser->start, parser->end, "parse depth limit over"); + return false; + } + const uint8_t *group_start = parser->cursor; pm_regexp_options_t options;