From 5be5aa42ae3175eea7c31c3937386388db390866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Joubert?= Date: Mon, 26 Oct 2026 20:09:21 +0300 Subject: [PATCH 1/4] Parse strings without allocating a temporary String Use ALLOCV for the scanned digits buffer (stack for short inputs) and check for Infinity/NaN only when the first character can start one. Co-Authored-By: Claude Opus 5.5 --- ext/bigdecimal/bigdecimal.c | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/ext/bigdecimal/bigdecimal.c b/ext/bigdecimal/bigdecimal.c index e934e340..cf76b4fd 100644 --- a/ext/bigdecimal/bigdecimal.c +++ b/ext/bigdecimal/bigdecimal.c @@ -4209,9 +4209,11 @@ VpAlloc(const char *szVal, int strict_p, int exc) while (ISSPACE(*szVal)) szVal++; /* Check on Inf & NaN */ - NULLABLE_BDVALUE special_bd = bigdecimal_parse_special_string(szVal); - if (special_bd.real_or_null != NULL) { - return special_bd; + if (*szVal == 'I' || *szVal == 'N' || *szVal == '+' || *szVal == '-') { + NULLABLE_BDVALUE special_bd = bigdecimal_parse_special_string(szVal); + if (special_bd.real_or_null != NULL) { + return special_bd; + } } /* Skip leading `#`. @@ -4223,8 +4225,7 @@ VpAlloc(const char *szVal, int strict_p, int exc) /* Scanning digits */ /* A buffer for keeping scanned digits */ - buf = rb_str_tmp_new(strlen(szVal) + 1); - psz = RSTRING_PTR(buf); + psz = ALLOCV_N(char, buf, strlen(szVal) + 1); /* cursor: i for psz, and j for szVal */ i = j = 0; @@ -4381,7 +4382,7 @@ VpAlloc(const char *szVal, int strict_p, int exc) Real *vp = VpPtr(obj); VpSetZero(vp, sign); VpCtoV(vp, psz, ni, psz + ipf, nf, psz + ipe, ne); - rb_str_resize(buf, 0); + ALLOCV_END(buf); return (NULLABLE_BDVALUE) { obj, vp }; } From 4a9c454195f85ec45d8eefd59fe01025badba0ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Joubert?= Date: Tue, 27 Oct 2026 09:41:28 +0300 Subject: [PATCH 2/4] Check the first character inside bigdecimal_parse_special_string The guard lives next to the table whose first characters it mirrors, and VpAlloc calls the parser unconditionally again. Co-Authored-By: Claude Opus 5.5 --- ext/bigdecimal/bigdecimal.c | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/ext/bigdecimal/bigdecimal.c b/ext/bigdecimal/bigdecimal.c index cf76b4fd..10cdb147 100644 --- a/ext/bigdecimal/bigdecimal.c +++ b/ext/bigdecimal/bigdecimal.c @@ -4155,6 +4155,14 @@ bigdecimal_parse_special_string(const char *str) static const size_t table_length = sizeof(table) / sizeof(table[0]); size_t i; + /* Fast path: every entry of the table starts with one of these */ + switch (*str) { + case 'I': case 'N': case '+': case '-': + break; + default: + return (NULLABLE_BDVALUE) { Qnil, NULL }; + } + for (i = 0; i < table_length; ++i) { const char *p; if (strncmp(str, table[i].str, table[i].len) != 0) { @@ -4209,11 +4217,9 @@ VpAlloc(const char *szVal, int strict_p, int exc) while (ISSPACE(*szVal)) szVal++; /* Check on Inf & NaN */ - if (*szVal == 'I' || *szVal == 'N' || *szVal == '+' || *szVal == '-') { - NULLABLE_BDVALUE special_bd = bigdecimal_parse_special_string(szVal); - if (special_bd.real_or_null != NULL) { - return special_bd; - } + NULLABLE_BDVALUE special_bd = bigdecimal_parse_special_string(szVal); + if (special_bd.real_or_null != NULL) { + return special_bd; } /* Skip leading `#`. From cdb9348d5c8f1c935beedfabb9cc50ae5baa9aac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Joubert?= Date: Tue, 27 Oct 2026 21:48:21 +0300 Subject: [PATCH 3/4] Free the digit buffer before leaving VpAlloc on invalid input On TruffleRuby, the ALLOCV_N heap fallback (inputs of 1023 bytes or more) is a plain malloc freed only by ALLOCV_END, so the invalid_value exits leaked it. Co-Authored-By: Claude Opus 5.5 --- ext/bigdecimal/bigdecimal.c | 1 + test/bigdecimal/test_bigdecimal.rb | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/ext/bigdecimal/bigdecimal.c b/ext/bigdecimal/bigdecimal.c index 10cdb147..a4031e9e 100644 --- a/ext/bigdecimal/bigdecimal.c +++ b/ext/bigdecimal/bigdecimal.c @@ -4371,6 +4371,7 @@ VpAlloc(const char *szVal, int strict_p, int exc) if (strict_p && ((ni == 0 && nf == 0) || (exp_seen && ne == 0))) { VALUE str; invalid_value: + ALLOCV_END(buf); if (!strict_p) { BDVALUE res = rbd_allocate_struct_zero_wrap(1, 1); return (NULLABLE_BDVALUE) { res.bigdecimal, res.real }; diff --git a/test/bigdecimal/test_bigdecimal.rb b/test/bigdecimal/test_bigdecimal.rb index a24bdbcc..81b74fcd 100644 --- a/test/bigdecimal/test_bigdecimal.rb +++ b/test/bigdecimal/test_bigdecimal.rb @@ -2256,6 +2256,20 @@ def test_split_under_gc_stress EOS end + def test_parse_long_string_under_gc_stress + paths = $LOAD_PATH.map{|path| "-I#{path}" } + assert_in_out_err([*paths, "-rbigdecimal", "--disable-gems"], <<-EOS, ["2001", "nil", "ArgumentError"], []) + GC.stress = true + p BigDecimal("1" + "0" * 2000).exponent + p BigDecimal("1" * 2000 + "x", exception: false) + begin + BigDecimal("1" * 2000 + "x") + rescue ArgumentError => e + puts e.class + end + EOS + end + def test_coerce_under_gc_stress paths = $LOAD_PATH.map{|path| "-I#{path}" } assert_in_out_err([*paths, "-rbigdecimal", "--disable-gems"], <<-EOS, [], []) From cf3291ca4f23d9548bd9b8f1bc2bcf10d3563c80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Joubert?= Date: Tue, 27 Oct 2026 21:51:43 +0300 Subject: [PATCH 4/4] Test that parsing a short string allocates only the result Co-Authored-By: Claude Opus 5.5 --- test/bigdecimal/test_bigdecimal.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/bigdecimal/test_bigdecimal.rb b/test/bigdecimal/test_bigdecimal.rb index 81b74fcd..fe92ecdf 100644 --- a/test/bigdecimal/test_bigdecimal.rb +++ b/test/bigdecimal/test_bigdecimal.rb @@ -2270,6 +2270,22 @@ def test_parse_long_string_under_gc_stress EOS end + def test_parse_string_allocations + omit "allocation counts are CRuby-specific" unless RUBY_ENGINE == "ruby" + paths = $LOAD_PATH.map{|path| "-I#{path}" } + assert_in_out_err([*paths, "-rbigdecimal", "-rbigdecimal/util", "--disable-gems"], <<-EOS, ["true", "true"], []) + def allocations + before = GC.stat(:total_allocated_objects) + yield + GC.stat(:total_allocated_objects) - before + end + BigDecimal("129.90") + "129.90".to_d + p allocations { 100.times { BigDecimal("129.90") } } < 250 + p allocations { 100.times { "129.90".to_d } } < 250 + EOS + end + def test_coerce_under_gc_stress paths = $LOAD_PATH.map{|path| "-I#{path}" } assert_in_out_err([*paths, "-rbigdecimal", "--disable-gems"], <<-EOS, [], [])