From 70c3baf696b44f4e5cc17ad361ca003214813a65 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 26 Oct 2026 13:27:27 -0400 Subject: [PATCH 01/12] Keep collation comparators alive when `collations` is mutated `Database#collations` returned the hash that held the only reference to each comparator sqlite could call. Clearing or editing it let GC free a comparator still registered with sqlite, and the next comparison used the freed object. A frozen hash had the same effect, because `Database#collation` raised `FrozenError` after sqlite had the comparator. `database_mark` also iterated that hash with `rb_hash_foreach`, which a mark function must not do. Track comparators in a private registry keyed by name without regard to ASCII case, as sqlite keys them, and pin an array snapshot of its values taken at registration. Prepare the key and registry before sqlite takes the pointer, and pin the snapshot before writing the public hash. --- ext/sqlite3/database.c | 58 +++++++++++++++++++++++++----------------- test/test_collation.rb | 50 ++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 23 deletions(-) diff --git a/ext/sqlite3/database.c b/ext/sqlite3/database.c index 92aebabc..0d1ddd86 100644 --- a/ext/sqlite3/database.c +++ b/ext/sqlite3/database.c @@ -87,22 +87,6 @@ rb_sqlite3_pin_array_and_contents(VALUE ary) } } -static int -pin_hash_value(VALUE UNUSED(key), VALUE value, VALUE UNUSED(arg)) -{ - rb_gc_mark(value); - return ST_CONTINUE; -} - -static void -pin_hash_and_contents(VALUE hash) -{ - if (NIL_P(hash) || !hash) { return; } - - rb_gc_mark(hash); - rb_hash_foreach(hash, pin_hash_value, 0); -} - /* Each wrapper also owns live aggregate instances, whose VALUEs sqlite keeps in * its own aggregate contexts. */ static void @@ -130,7 +114,7 @@ database_mark(void *ctx) rb_gc_mark(c->authorizer); rb_sqlite3_pin_array_and_contents(c->functions); - pin_hash_and_contents(c->collations); + rb_sqlite3_pin_array_and_contents(c->collations); pin_aggregators(c->aggregators); } @@ -843,6 +827,13 @@ rb_comparator_func(void *ctx, int a_len, const void *a, int b_len, const void *b return NUM2INT(comparison); } +static int +push_hash_value(VALUE UNUSED(key), VALUE value, VALUE ary) +{ + rb_ary_push(ary, value); + return ST_CONTINUE; +} + /* call-seq: db.collation(name, comparator) * * Add a collation with name +name+, and a +comparator+ object. The @@ -854,21 +845,42 @@ static VALUE collation(VALUE self, VALUE name, VALUE comparator) { sqlite3RubyPtr ctx; - VALUE collations; + VALUE registry, key, comparators; + char *key_ptr; + long i; TypedData_Get_Struct(self, sqlite3Ruby, &database_type, ctx); REQUIRE_OPEN_DB(ctx); + /* sqlite holds a raw pointer to the comparator, so keep it alive and + * unmoved. The registry is private because callers can mutate the public + * @collations hash, and the snapshot is an array so database_mark can pin + * it without iterating a hash. sqlite matches collation names without + * regard to ASCII case, so the registry does too. The key and registry + * are prepared before sqlite takes the pointer, and the snapshot is + * published before the public hash, whose write can raise. */ + key = rb_str_new_cstr(StringValuePtr(name)); + key_ptr = RSTRING_PTR(key); + for (i = 0; i < RSTRING_LEN(key); i++) { + if (key_ptr[i] >= 'A' && key_ptr[i] <= 'Z') { key_ptr[i] += 'a' - 'A'; } + } + if (!rb_ivar_defined(self, rb_intern("-collations"))) { + rb_iv_set(self, "-collations", rb_hash_new()); + } + registry = rb_iv_get(self, "-collations"); + CHECK(ctx->db, sqlite3_create_collation( ctx->db, - StringValuePtr(name), + RSTRING_PTR(name), SQLITE_UTF8, (void *)comparator, NIL_P(comparator) ? NULL : rb_comparator_func)); - /* sqlite holds a raw pointer to the comparator, so keep it alive and unmoved. */ - collations = rb_iv_get(self, "@collations"); - rb_hash_aset(collations, name, comparator); - RB_OBJ_WRITE(self, &ctx->collations, collations); + rb_hash_aset(registry, key, comparator); + comparators = rb_ary_new(); + rb_hash_foreach(registry, push_hash_value, comparators); + RB_OBJ_WRITE(self, &ctx->collations, comparators); + + rb_hash_aset(rb_iv_get(self, "@collations"), name, comparator); return self; } diff --git a/test/test_collation.rb b/test/test_collation.rb index d33e58ff..6c919b3c 100644 --- a/test/test_collation.rb +++ b/test/test_collation.rb @@ -65,6 +65,56 @@ def test_replacing_a_collation_releases_the_previous_comparator assert_equal 1, ObjectSpace.each_object(ReleasableComparator).count end + def test_clearing_collations_does_not_release_a_registered_comparator + Thread.new { @db.collation "foo", Comparator.new }.join + + @db.collations.clear + GC.start(full_mark: true, immediate_sweep: true) + + assert_equal [["hello"], ["world"]], @db.execute("select data from ex order by 1 collate foo") + end + + def test_collation_on_frozen_collations_does_not_release_the_comparator + @db.collations.freeze + Thread.new do + assert_raises(FrozenError) { @db.collation "foo", Comparator.new } + end.join + + GC.start(full_mark: true, immediate_sweep: true) + + assert_equal [["hello"], ["world"]], @db.execute("select data from ex order by 1 collate foo") + end + + def test_replacing_a_collation_by_another_case_releases_the_previous_comparator + comparator_class = Class.new(ReleasableComparator) + ["foo", "FOO", "Foo"].each { |name| @db.collation name, comparator_class.new } + @db.collations.clear + GC.start(full_mark: true, immediate_sweep: true) + + assert_equal 1, ObjectSpace.each_object(comparator_class).count + end + + def test_collation_on_a_frozen_database_does_not_register_the_comparator + skip("FrozenError from the extension's rb_iv_set is CRuby-specific") unless RUBY_ENGINE == "ruby" + + @db.freeze + assert_raises(FrozenError) { @db.collation "foo", Comparator.new } + + assert_raises(SQLite3::SQLException) { @db.execute("select data from ex order by 1 collate foo") } + end + + def test_collation_registry_ignores_an_overridden_downcase + Thread.new { @db.collation "foo", Comparator.new }.join + name = +"bar" + def name.downcase(*) = "foo" + @db.collation name, Comparator.new + + @db.collations.clear + GC.start(full_mark: true, immediate_sweep: true) + + assert_equal [["hello"], ["world"]], @db.execute("select data from ex order by 1 collate foo") + end + def test_remove_collation comparator = Comparator.new From f0aba251713cd0a6446baf6f2ee0d0aa18dcd94d Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 26 Oct 2026 13:28:24 -0400 Subject: [PATCH 02/12] Intern `-instances` once, outside the GC mark path `rb_sqlite3_aggregator_pin_instances` interned `"-instances"` on every major GC. Interning a new name allocates, which is fatal during GC; the call was safe only because `rb_sqlite3_define_aggregator2` interned the name first. Cache the ID at init. Move all aggregator pinning into `rb_sqlite3_aggregator_pin`, so `database.c` no longer reaches into the wrappers' ivars, and move `rb_sqlite3_pin_array_and_contents` from `database.c` to `sqlite3.c`, which both callers share. --- ext/sqlite3/aggregator.c | 17 +++++++++++++++-- ext/sqlite3/aggregator.h | 7 ++++--- ext/sqlite3/database.c | 31 +------------------------------ ext/sqlite3/database.h | 3 --- ext/sqlite3/sqlite3.c | 13 +++++++++++++ ext/sqlite3/sqlite3_ruby.h | 3 +++ 6 files changed, 36 insertions(+), 38 deletions(-) diff --git a/ext/sqlite3/aggregator.c b/ext/sqlite3/aggregator.c index 8a380b88..7e73cd51 100644 --- a/ext/sqlite3/aggregator.c +++ b/ext/sqlite3/aggregator.c @@ -20,6 +20,10 @@ static VALUE cAggregatorWrapper; * `step` and `finalize` won't be called any more. */ static VALUE cAggregatorInstance; +/* interned at init, because interning can allocate and rb_sqlite3_aggregator_pin + * runs during GC */ +static ID id_instances; + typedef struct rb_sqlite3_protected_funcall_args { VALUE self; ID method; @@ -267,9 +271,16 @@ rb_sqlite3_define_aggregator2(VALUE self, VALUE aggregator, VALUE ruby_name) } void -rb_sqlite3_aggregator_pin_instances(VALUE aw) +rb_sqlite3_aggregator_pin(VALUE aggregators) { - rb_sqlite3_pin_array_and_contents(rb_iv_get(aw, "-instances")); + long i; + + if (NIL_P(aggregators) || !aggregators) { return; } + + rb_sqlite3_pin_array_and_contents(aggregators); + for (i = 0; i < RARRAY_LEN(aggregators); i++) { + rb_sqlite3_pin_array_and_contents(rb_ivar_get(RARRAY_AREF(aggregators, i), id_instances)); + } } void @@ -281,4 +292,6 @@ rb_sqlite3_aggregator_init(void) cAggregatorInstance = rb_funcall(rb_cClass, rb_intern("new"), 0); rb_gc_register_mark_object(cAggregatorInstance); + + id_instances = rb_intern("-instances"); } diff --git a/ext/sqlite3/aggregator.h b/ext/sqlite3/aggregator.h index bee9811b..a12fe67e 100644 --- a/ext/sqlite3/aggregator.h +++ b/ext/sqlite3/aggregator.h @@ -7,8 +7,9 @@ VALUE rb_sqlite3_define_aggregator2(VALUE self, VALUE aggregator, VALUE ruby_nam void rb_sqlite3_aggregator_init(void); -/* sqlite stores each live instance's VALUE in its aggregate context, so those - * must not move either. */ -void rb_sqlite3_aggregator_pin_instances(VALUE aw); +/* Pins every aggregator wrapper and its live instances. sqlite holds raw + * pointers to the wrappers, and stores each instance's VALUE in its aggregate + * context. */ +void rb_sqlite3_aggregator_pin(VALUE aggregators); #endif diff --git a/ext/sqlite3/database.c b/ext/sqlite3/database.c index 0d1ddd86..e92b329b 100644 --- a/ext/sqlite3/database.c +++ b/ext/sqlite3/database.c @@ -74,35 +74,6 @@ close_or_discard_db(sqlite3RubyPtr ctx) } -void -rb_sqlite3_pin_array_and_contents(VALUE ary) -{ - long i; - - if (NIL_P(ary) || !ary) { return; } - - rb_gc_mark(ary); - for (i = 0; i < RARRAY_LEN(ary); i++) { - rb_gc_mark(RARRAY_AREF(ary, i)); - } -} - -/* Each wrapper also owns live aggregate instances, whose VALUEs sqlite keeps in - * its own aggregate contexts. */ -static void -pin_aggregators(VALUE aggregators) -{ - long i; - - rb_sqlite3_pin_array_and_contents(aggregators); - - if (NIL_P(aggregators) || !aggregators) { return; } - - for (i = 0; i < RARRAY_LEN(aggregators); i++) { - rb_sqlite3_aggregator_pin_instances(RARRAY_AREF(aggregators, i)); - } -} - static void database_mark(void *ctx) { @@ -115,7 +86,7 @@ database_mark(void *ctx) rb_sqlite3_pin_array_and_contents(c->functions); rb_sqlite3_pin_array_and_contents(c->collations); - pin_aggregators(c->aggregators); + rb_sqlite3_aggregator_pin(c->aggregators); } static void diff --git a/ext/sqlite3/database.h b/ext/sqlite3/database.h index 62fe9071..fe065130 100644 --- a/ext/sqlite3/database.h +++ b/ext/sqlite3/database.h @@ -24,9 +24,6 @@ struct _sqlite3Ruby { typedef struct _sqlite3Ruby sqlite3Ruby; typedef sqlite3Ruby *sqlite3RubyPtr; -/* Pinning a collection doesn't pin what's in it, hence both. */ -void rb_sqlite3_pin_array_and_contents(VALUE ary); - void init_sqlite3_database(); void set_sqlite3_func_result(sqlite3_context *ctx, VALUE result); diff --git a/ext/sqlite3/sqlite3.c b/ext/sqlite3/sqlite3.c index d30e85d5..dd118e56 100644 --- a/ext/sqlite3/sqlite3.c +++ b/ext/sqlite3/sqlite3.c @@ -60,6 +60,19 @@ bignum_to_int64(VALUE value, sqlite3_int64 *result) #endif } +void +rb_sqlite3_pin_array_and_contents(VALUE ary) +{ + long i; + + if (NIL_P(ary) || !ary) { return; } + + rb_gc_mark(ary); + for (i = 0; i < RARRAY_LEN(ary); i++) { + rb_gc_mark(RARRAY_AREF(ary, i)); + } +} + static VALUE libversion(VALUE UNUSED(klass)) { diff --git a/ext/sqlite3/sqlite3_ruby.h b/ext/sqlite3/sqlite3_ruby.h index 088d3cd5..6f8e2523 100644 --- a/ext/sqlite3/sqlite3_ruby.h +++ b/ext/sqlite3/sqlite3_ruby.h @@ -45,4 +45,7 @@ extern VALUE cSqlite3Blob; int bignum_to_int64(VALUE big, sqlite3_int64 *result); +/* Pinning a collection doesn't pin what's in it, hence both. */ +void rb_sqlite3_pin_array_and_contents(VALUE ary); + #endif From f0adbe3dcbd58f28ccc6a8e29185d95140723898 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 26 Oct 2026 13:28:51 -0400 Subject: [PATCH 03/12] Give the `execute_batch2` callbacks the `sqlite3_callback` signature `hash_callback_function` and `regular_callback_function` took a `VALUE` first parameter and were cast to `sqlite3_callback`, whose first parameter is `void *`. Calling through an incompatible function-pointer type is undefined behaviour, and a build with CFI or `-fsanitize=function` can reject it. Take `void *` and convert it inside each callback. --- ext/sqlite3/database.c | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ext/sqlite3/database.c b/ext/sqlite3/database.c index e92b329b..4196cdd2 100644 --- a/ext/sqlite3/database.c +++ b/ext/sqlite3/database.c @@ -918,8 +918,9 @@ transaction_active_p(VALUE self) } static int -hash_callback_function(VALUE callback_ary, int count, char **data, char **columns) +hash_callback_function(void *data_ptr, int count, char **data, char **columns) { + VALUE callback_ary = (VALUE)data_ptr; VALUE new_hash = rb_hash_new(); int i; @@ -937,8 +938,9 @@ hash_callback_function(VALUE callback_ary, int count, char **data, char **column } static int -regular_callback_function(VALUE callback_ary, int count, char **data, char **columns) +regular_callback_function(void *data_ptr, int count, char **data, char **columns) { + VALUE callback_ary = (VALUE)data_ptr; VALUE new_ary = rb_ary_new(); int i; @@ -978,11 +980,11 @@ exec_batch(VALUE self, VALUE sql, VALUE results_as_hash) timespecclear(&ctx->stmt_deadline); if (results_as_hash == Qtrue) { - status = sqlite3_exec(ctx->db, StringValuePtr(sql), (sqlite3_callback)hash_callback_function, + status = sqlite3_exec(ctx->db, StringValuePtr(sql), hash_callback_function, (void *)callback_ary, &errMsg); } else { - status = sqlite3_exec(ctx->db, StringValuePtr(sql), (sqlite3_callback)regular_callback_function, + status = sqlite3_exec(ctx->db, StringValuePtr(sql), regular_callback_function, (void *)callback_ary, &errMsg); } From 0da27849f1e9e1b2837c909538f7012e7102ba2b Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 26 Oct 2026 13:29:22 -0400 Subject: [PATCH 04/12] Correct the Database and Statement typed-data names Both types were named `"SQLite3::Backup"`, a copy-paste error that showed up in `ObjectSpace.dump` output and heap profiles. --- ext/sqlite3/database.c | 2 +- ext/sqlite3/statement.c | 2 +- test/test_database.rb | 8 ++++++++ test/test_statement.rb | 8 ++++++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/ext/sqlite3/database.c b/ext/sqlite3/database.c index 4196cdd2..1b23f366 100644 --- a/ext/sqlite3/database.c +++ b/ext/sqlite3/database.c @@ -105,7 +105,7 @@ database_memsize(const void *ctx) } static const rb_data_type_t database_type = { - .wrap_struct_name = "SQLite3::Backup", + .wrap_struct_name = "SQLite3::Database", .function = { .dmark = database_mark, .dfree = deallocate, diff --git a/ext/sqlite3/statement.c b/ext/sqlite3/statement.c index 35a2cd4e..bf92beed 100644 --- a/ext/sqlite3/statement.c +++ b/ext/sqlite3/statement.c @@ -31,7 +31,7 @@ statement_memsize(const void *data) } static const rb_data_type_t statement_type = { - "SQLite3::Backup", + "SQLite3::Statement", { NULL, statement_deallocate, diff --git a/test/test_database.rb b/test/test_database.rb index 555b0b5d..7d469520 100644 --- a/test/test_database.rb +++ b/test/test_database.rb @@ -36,6 +36,14 @@ def load_extension_internal(path) end end + def test_typed_data_struct_name + skip("ObjectSpace.dump is CRuby-specific") unless RUBY_ENGINE == "ruby" + require "objspace" + require "json" + + assert_equal "SQLite3::Database", JSON.parse(ObjectSpace.dump(@db))["struct"] + end + def test_custom_function_encoding @db.execute("CREATE TABLE sourceTable( diff --git a/test/test_statement.rb b/test/test_statement.rb index d7497032..7fb5d155 100644 --- a/test/test_statement.rb +++ b/test/test_statement.rb @@ -12,6 +12,14 @@ def teardown @db.close end + def test_typed_data_struct_name + skip("ObjectSpace.dump is CRuby-specific") unless RUBY_ENGINE == "ruby" + require "objspace" + require "json" + + assert_equal "SQLite3::Statement", JSON.parse(ObjectSpace.dump(@stmt))["struct"] + end + def test_rows_should_be_frozen @db.execute 'CREATE TABLE "things" ("float" float, "int" int, "text" blob, "string" string, "nil" string)' stmt = @db.prepare "INSERT INTO things (float, int, text, string, nil) VALUES (?, ?, ?, ?, ?)" From 25b598d0710c4611ff3b6f140b5d805ead77d320 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 26 Oct 2026 13:29:49 -0400 Subject: [PATCH 05/12] doc: explain why collations release on replace and functions do not Record the reason beside the struct fields, so nobody makes the two consistent in either direction. --- ext/sqlite3/database.h | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ext/sqlite3/database.h b/ext/sqlite3/database.h index fe065130..c744d83e 100644 --- a/ext/sqlite3/database.h +++ b/ext/sqlite3/database.h @@ -10,6 +10,12 @@ struct _sqlite3Ruby { sqlite3 *db; VALUE busy_handler; + /* sqlite holds raw pointers to everything in these arrays. functions and + * aggregators are append-only, so redefining a name keeps the old object + * for the life of the connection: sqlite keys them by name and arity, and + * releasing on name alone was GHSA-28hh-pr2h-2w89. collations is rebuilt + * from a private hash keyed by name without regard to ASCII case, as + * sqlite keys them, so it releases a replaced comparator. */ VALUE functions; VALUE collations; VALUE aggregators; From 621c692ba4fffffca2cfc30d84d48ec00938ab97 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 26 Oct 2026 13:30:41 -0400 Subject: [PATCH 06/12] test: skip compaction tests where compaction raises `skip_unless_compaction_supported` checked only that `GC.verify_compaction_references` was defined. A runtime that defines it and raises `NotImplementedError` errored those tests instead of skipping them. Delegate to `gc_compaction_verifiable?`, which tries a compaction, and memoize it so the probe runs once. --- test/helper.rb | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/test/helper.rb b/test/helper.rb index bffae82d..d785f3c2 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -27,6 +27,12 @@ def gc_level @gc_level ||= detect_gc_level end + def gc_compaction_verifiable? + return @gc_compaction_verifiable if defined?(@gc_compaction_verifiable) + + @gc_compaction_verifiable = GC.respond_to?(:verify_compaction_references) && gc_compaction_supported? + end + private def detect_gc_level @@ -48,10 +54,6 @@ def gc_compaction_supported? warn("#{__FILE__}:#{__LINE__}: GC compaction is not supported by this platform") false end - - def gc_compaction_verifiable? - gc_compaction_supported? && GC.respond_to?(:verify_compaction_references) - end end self.test_count = 0 @@ -107,7 +109,7 @@ def gc_verify_compaction_references end def skip_unless_compaction_supported - skip("GC compaction is unsupported on this runtime") unless GC.respond_to?(:verify_compaction_references) + skip("GC compaction is unsupported on this runtime") unless TestCase.gc_compaction_verifiable? end def assert_nothing_raised From 03e54697a6c0bd9f075e2cf7c71d98aac48eb1e4 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 26 Oct 2026 13:32:00 -0400 Subject: [PATCH 07/12] test: release aggregate instances on more routes to xFinal Cover a query over no rows, where the instance is created and destroyed in the same `xFinal`; an exception in `step`; and an interrupt. --- test/test_integration_aggregate.rb | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/test/test_integration_aggregate.rb b/test/test_integration_aggregate.rb index 9d9a59d8..dbcdf915 100644 --- a/test/test_integration_aggregate.rb +++ b/test/test_integration_aggregate.rb @@ -440,6 +440,47 @@ def test_aggregate_instances_are_released_after_each_query assert_equal 1, ObjectSpace.each_object(ReleasableAggregator).count end + def test_aggregate_instance_is_released_after_a_query_over_no_rows + aggregator = Class.new(AccumulateAggregator) + @db.define_aggregator("accumulate", aggregator.new) + + 5.times { assert_nil @db.get_first_value("select accumulate(c) from foo where a = 100") } + GC.start(full_mark: true, immediate_sweep: true) + + assert_equal 1, ObjectSpace.each_object(aggregator).count + end + + def test_aggregate_instance_is_released_after_an_exception_in_step + aggregator = Class.new(AccumulateAggregator) do + def step(*args) + raise CustomException + end + end + @db.define_aggregator("accumulate", aggregator.new) + + 5.times do + assert_raises(CustomException) { @db.get_first_value("select accumulate(c) from foo") } + end + GC.start(full_mark: true, immediate_sweep: true) + + assert_equal 1, ObjectSpace.each_object(aggregator).count + end + + def test_aggregate_instance_is_released_after_an_interrupt + db = @db + aggregator = Class.new(AccumulateAggregator) do + define_method(:step) { |*args| db.interrupt } + end + @db.define_aggregator("accumulate", aggregator.new) + + 5.times do + assert_raises(SQLite3::InterruptException) { @db.get_first_value("select accumulate(c) from foo") } + end + GC.start(full_mark: true, immediate_sweep: true) + + assert_equal 1, ObjectSpace.each_object(aggregator).count + end + def test_step_on_statement_whose_database_was_closed_does_not_use_freed_aggregator @db.define_aggregator("accumulate", AccumulateAggregator.new) stmt = @db.prepare("select accumulate(c) from foo") From dab7eb26ef98dc1d94dc209ceabec5d98323df1d Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 26 Oct 2026 13:34:06 -0400 Subject: [PATCH 08/12] doc: update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1e6d77b..7676fd69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Raise `RangeError` when a SQL string, a bound String or Blob value, or a String or Blob returned from a custom function or aggregate is longer than `INT_MAX` bytes. Previously the length was truncated to 32 bits, so SQLite could read only part of the string and drop trailing clauses from a query. See [GHSA-4fqr-96q8-xrfx](https://github.com/sparklemotion/sqlite3-ruby/security/advisories/GHSA-4fqr-96q8-xrfx) for more information. #762 @flavorjones - Fix a crash when a custom aggregate's `finalize` returns a value that can't be converted to a SQLite result. The exception is now raised from the query and the connection remains usable. #762 @flavorjones +- Fix a use-after-free when the hash returned by `Database#collations` is cleared, modified or frozen. GC could free a comparator that SQLite still called. @flavorjones ### Improved From ab14f58ce807663e05dc39d66905b8eb16cf17fe Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 26 Oct 2026 14:01:25 -0400 Subject: [PATCH 09/12] doc: add PR number to CHANGELOG --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7676fd69..4f08e9a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,10 @@ - Raise `RangeError` when a SQL string, a bound String or Blob value, or a String or Blob returned from a custom function or aggregate is longer than `INT_MAX` bytes. Previously the length was truncated to 32 bits, so SQLite could read only part of the string and drop trailing clauses from a query. See [GHSA-4fqr-96q8-xrfx](https://github.com/sparklemotion/sqlite3-ruby/security/advisories/GHSA-4fqr-96q8-xrfx) for more information. #762 @flavorjones - Fix a crash when a custom aggregate's `finalize` returns a value that can't be converted to a SQLite result. The exception is now raised from the query and the connection remains usable. #762 @flavorjones -- Fix a use-after-free when the hash returned by `Database#collations` is cleared, modified or frozen. GC could free a comparator that SQLite still called. @flavorjones +- Fix a use-after-free when the hash returned by `Database#collations` is cleared, modified or frozen. GC could free a comparator that SQLite still called. #764 @flavorjones +- Fix a deadlock when a collation comparator, `trace` block, `busy_handler` block or authorizer raises. Another thread that used the connection blocked forever. The exception is now raised from the query and the connection remains usable. #764 @flavorjones +- Fix a crash when a custom function or aggregate uses `throw`. #764 @flavorjones +- `Database#execute_batch2` now raises the exception from a custom function, instead of `SQLite3::SQLException` "Ruby Exception occurred". #764 @flavorjones ### Improved From 083d734e592d2dde4eb5eec57a28b99447e586e3 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 26 Oct 2026 14:50:48 -0400 Subject: [PATCH 10/12] Stop a raise inside a callback from deadlocking other threads When a collation comparator, `trace` block, `busy_handler` block or authorizer raised, the exception unwound through sqlite and left sqlite's per-connection mutex locked by the raising thread. Another thread that then used the connection blocked forever. An interrupt such as `Thread#raise` that arrived while an aggregate's handler was being created did the same. A `throw` from a scalar function or aggregate crashed the process. Run all of a callback's Ruby code, including aggregate handler creation and `execute_batch2`'s row callback, through `rb_sqlite3_protect_callback`. Statement preparation, `Statement#step`, `Database#execute_batch2` and `Backup#step` push a frame around each sqlite call. A failed callback records its `rb_protect` state in that frame, and later callbacks in the call skip their Ruby code. After sqlite returns, the caller pops its frame and resumes Ruby's pending jump. Each call has its own frame, so a nested call, such as a query in a GC finalizer, never resumes another call's jump. The frame chain is fiber-local, so one fiber's frames never mix with another's. `execute_batch2` used to raise `SQLite3::SQLException` "Ruby Exception occurred" for a raise inside a function; it now raises the function's exception. --- CHANGELOG.md | 2 +- ext/sqlite3/aggregator.c | 197 +++++++++++++--------------- ext/sqlite3/backup.c | 9 +- ext/sqlite3/database.c | 203 +++++++++++++++++++++-------- ext/sqlite3/exception.c | 95 ++++++++++++++ ext/sqlite3/exception.h | 21 +++ ext/sqlite3/sqlite3.c | 1 + ext/sqlite3/statement.c | 27 ++-- test/helper.rb | 32 +++++ test/test_backup.rb | 22 ++++ test/test_collation.rb | 24 ++++ test/test_database.rb | 127 ++++++++++++++++++ test/test_integration_aggregate.rb | 34 +++++ test/test_integration_pending.rb | 26 ++++ 14 files changed, 641 insertions(+), 179 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f08e9a8..d94ef83b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - Raise `RangeError` when a SQL string, a bound String or Blob value, or a String or Blob returned from a custom function or aggregate is longer than `INT_MAX` bytes. Previously the length was truncated to 32 bits, so SQLite could read only part of the string and drop trailing clauses from a query. See [GHSA-4fqr-96q8-xrfx](https://github.com/sparklemotion/sqlite3-ruby/security/advisories/GHSA-4fqr-96q8-xrfx) for more information. #762 @flavorjones - Fix a crash when a custom aggregate's `finalize` returns a value that can't be converted to a SQLite result. The exception is now raised from the query and the connection remains usable. #762 @flavorjones - Fix a use-after-free when the hash returned by `Database#collations` is cleared, modified or frozen. GC could free a comparator that SQLite still called. #764 @flavorjones -- Fix a deadlock when a collation comparator, `trace` block, `busy_handler` block or authorizer raises. Another thread that used the connection blocked forever. The exception is now raised from the query and the connection remains usable. #764 @flavorjones +- Fix a deadlock when a collation comparator, `trace` block, `busy_handler` block or authorizer raises, or when an interrupt such as `Thread#raise` arrives while an aggregate's handler is being created. Another thread that used the connection blocked forever. The exception is now raised from the query and the connection remains usable. #764 @flavorjones - Fix a crash when a custom function or aggregate uses `throw`. #764 @flavorjones - `Database#execute_batch2` now raises the exception from a custom function, instead of `SQLite3::SQLException` "Ruby Exception occurred". #764 @flavorjones diff --git a/ext/sqlite3/aggregator.c b/ext/sqlite3/aggregator.c index 7e73cd51..9e4ddf51 100644 --- a/ext/sqlite3/aggregator.c +++ b/ext/sqlite3/aggregator.c @@ -14,173 +14,149 @@ static VALUE cAggregatorWrapper; * the xFinal callback. * * An AggregatorInstance holds the following instance variables: - * -handler_instance: the instance to call `step` and `finalize` on. - * -exc_status: status returned by rb_protect. - * != 0 if an exception occurred. If an exception occurred - * `step` and `finalize` won't be called any more. */ + * -handler_instance: the instance to call `step` and `finalize` on. */ static VALUE cAggregatorInstance; /* interned at init, because interning can allocate and rb_sqlite3_aggregator_pin * runs during GC */ static ID id_instances; +static ID id_handler_instance; -typedef struct rb_sqlite3_protected_funcall_args { - VALUE self; - ID method; +/* sqlite's aggregate context for one invocation of an aggregate function. */ +typedef struct { + /* the AggregatorInstance: Qfalse until the first callback creates it, Qnil + * once xFinal destroys it */ + VALUE inst; + /* != 0 once a callback for this invocation raised, threw or was skipped. + * After that, `step` and `finalize` won't be called any more. */ + int exc_status; +} aggregate_context_t; + +typedef struct { + sqlite3_context *ctx; + aggregate_context_t *agg; int argc; - VALUE *params; - sqlite3_context *result_ctx; -} protected_funcall_args_t; - -/* why isn't there something like this in the ruby API? */ -static VALUE -rb_sqlite3_protected_funcall_body(VALUE protected_funcall_args_ptr) -{ - protected_funcall_args_t *args = - (protected_funcall_args_t *)protected_funcall_args_ptr; - - VALUE result = rb_funcall2(args->self, args->method, args->argc, args->params); - - if (args->result_ctx) { - set_sqlite3_func_result(args->result_ctx, result); - } - - return result; -} + sqlite3_value **argv; +} aggregator_call_t; +/* called in the step and final callbacks. Returns the invocation's handler + * instance, creating it on the first call. */ static VALUE -rb_sqlite3_protected_funcall(VALUE self, ID method, int argc, VALUE *params, - sqlite3_context *result_ctx, int *exc_status) +rb_sqlite3_aggregate_handler_instance(aggregator_call_t *call) { - protected_funcall_args_t args = { - .self = self, .method = method, .argc = argc, .params = params, .result_ctx = result_ctx - }; - return rb_protect(rb_sqlite3_protected_funcall_body, (VALUE)(&args), exc_status); -} + VALUE aw, handler_instance, inst; -/* called in rb_sqlite3_aggregator_step and rb_sqlite3_aggregator_final. It - * checks if the execution context already has an associated instance. If it - * has one, it returns it. If there is no instance yet, it creates one and - * associates it with the context. */ -static VALUE -rb_sqlite3_aggregate_instance(sqlite3_context *ctx) -{ - VALUE aw = (VALUE) sqlite3_user_data(ctx); - VALUE handler_klass = rb_iv_get(aw, "-handler_klass"); - VALUE inst; - VALUE *inst_ptr = sqlite3_aggregate_context(ctx, (int)sizeof(VALUE)); - - if (!inst_ptr) { - rb_fatal("SQLite is out-of-memory"); + if (call->agg->inst == Qnil) { + rb_fatal("SQLite called us back on an already destroyed aggregate instance"); } - inst = *inst_ptr; - - if (inst == Qfalse) { /* Qfalse == 0 */ - VALUE instances = rb_iv_get(aw, "-instances"); - int exc_status; + if (call->agg->inst == Qfalse) { /* Qfalse == 0 */ + aw = (VALUE) sqlite3_user_data(call->ctx); + handler_instance = rb_funcall(rb_ivar_get(aw, rb_intern("-handler_klass")), rb_intern("new"), 0); inst = rb_class_new_instance(0, NULL, cAggregatorInstance); - rb_iv_set(inst, "-handler_instance", rb_sqlite3_protected_funcall( - handler_klass, rb_intern("new"), 0, NULL, NULL, &exc_status)); - rb_iv_set(inst, "-exc_status", INT2NUM(exc_status)); - - rb_ary_push(instances, inst); + rb_ivar_set(inst, id_handler_instance, handler_instance); + rb_ary_push(rb_ivar_get(aw, id_instances), inst); - *inst_ptr = inst; + call->agg->inst = inst; } - if (inst == Qnil) { - rb_fatal("SQLite called us back on an already destroyed aggregate instance"); - } - - return inst; + return rb_ivar_get(call->agg->inst, id_handler_instance); } -/* called by rb_sqlite3_aggregator_final. Unlinks and frees the - * aggregator_instance_t, so the handler_instance won't be marked any more - * and Ruby's GC may free it. */ +/* called by rb_sqlite3_aggregator_final, after any skipped or failed callback + * too, so it runs no Ruby code. Unlinks the instance, so the handler_instance + * won't be marked any more and Ruby's GC may free it. */ static void -rb_sqlite3_aggregate_instance_destroy(sqlite3_context *ctx) +rb_sqlite3_aggregate_instance_destroy(sqlite3_context *ctx, aggregate_context_t *agg) { - VALUE aw = (VALUE) sqlite3_user_data(ctx); - VALUE instances = rb_iv_get(aw, "-instances"); - VALUE *inst_ptr = sqlite3_aggregate_context(ctx, 0); - VALUE inst; + VALUE instances = rb_ivar_get((VALUE) sqlite3_user_data(ctx), id_instances); + long i; - if (!inst_ptr || !(inst = *inst_ptr)) { + if (!agg->inst) { return; } - if (inst == Qnil) { + if (agg->inst == Qnil) { rb_fatal("attempt to destroy aggregate instance twice"); } - rb_iv_set(inst, "-handler_instance", Qnil); // may catch use-after-free - if (rb_ary_delete(instances, inst) == Qnil) { + rb_ivar_set(agg->inst, id_handler_instance, Qnil); // may catch use-after-free + for (i = 0; i < RARRAY_LEN(instances) && RARRAY_AREF(instances, i) != agg->inst; i++); + if (i == RARRAY_LEN(instances)) { rb_fatal("must be in instances at that point"); } + rb_ary_delete_at(instances, i); - *inst_ptr = Qnil; + agg->inst = Qnil; +} + +static VALUE +rb_sqlite3_aggregator_step_protected(VALUE call_value) +{ + aggregator_call_t *call = (aggregator_call_t *)call_value; + VALUE handler_instance = rb_sqlite3_aggregate_handler_instance(call); + VALUE params = rb_ary_new2(call->argc); + int i; + + for (i = 0; i < call->argc; i++) { + rb_ary_push(params, sqlite3val2rb(call->argv[i])); + } + + return rb_apply(handler_instance, rb_intern("step"), params); +} + +static VALUE +rb_sqlite3_aggregator_final_protected(VALUE call_value) +{ + aggregator_call_t *call = (aggregator_call_t *)call_value; + VALUE handler_instance = rb_sqlite3_aggregate_handler_instance(call); + + set_sqlite3_func_result(call->ctx, rb_funcall(handler_instance, rb_intern("finalize"), 0)); + + return Qnil; } static void rb_sqlite3_aggregator_step(sqlite3_context *ctx, int argc, sqlite3_value **argv) { - VALUE inst = rb_sqlite3_aggregate_instance(ctx); - VALUE handler_instance = rb_iv_get(inst, "-handler_instance"); - VALUE *params = NULL; - VALUE params_handle = 0; - VALUE one_param; - int exc_status = NUM2INT(rb_iv_get(inst, "-exc_status")); - int i; + aggregator_call_t call = { + .ctx = ctx, .agg = sqlite3_aggregate_context(ctx, (int)sizeof(aggregate_context_t)), .argc = argc, .argv = argv + }; - if (exc_status) { + if (!call.agg) { + sqlite3_result_error_nomem(ctx); return; } - if (argc == 1) { - one_param = sqlite3val2rb(argv[0]); - params = &one_param; - } - if (argc > 1) { - /* ALLOCV memory is conservatively marked, so stored VALUEs survive a - * GC raised by a later sqlite3val2rb call. */ - params = ALLOCV_N(VALUE, params_handle, argc); - for (i = 0; i < argc; i++) { - params[i] = sqlite3val2rb(argv[i]); - } + if (!call.agg->exc_status) { + rb_sqlite3_protect_callback(rb_sqlite3_aggregator_step_protected, (VALUE)&call, &call.agg->exc_status); } - rb_sqlite3_protected_funcall( - handler_instance, rb_intern("step"), argc, params, NULL, &exc_status); - if (argc > 1) { - ALLOCV_END(params_handle); - } - - rb_iv_set(inst, "-exc_status", INT2NUM(exc_status)); } /* we assume that this function is only called once per execution context */ static void rb_sqlite3_aggregator_final(sqlite3_context *ctx) { - VALUE inst = rb_sqlite3_aggregate_instance(ctx); - VALUE handler_instance = rb_iv_get(inst, "-handler_instance"); - int exc_status = NUM2INT(rb_iv_get(inst, "-exc_status")); + aggregator_call_t call = { .ctx = ctx, .agg = sqlite3_aggregate_context(ctx, (int)sizeof(aggregate_context_t)) }; + + if (!call.agg) { + sqlite3_result_error_nomem(ctx); + return; + } - if (!exc_status) { - rb_sqlite3_protected_funcall( - handler_instance, rb_intern("finalize"), 0, NULL, ctx, &exc_status); + if (!call.agg->exc_status) { + rb_sqlite3_protect_callback(rb_sqlite3_aggregator_final_protected, (VALUE)&call, &call.agg->exc_status); } - if (exc_status) { + if (call.agg->exc_status) { /* the user should never see this, as Statement.step() will pick up the * outstanding exception and raise it instead of generating a new one * for SQLITE_ERROR with message "Ruby Exception occurred" */ sqlite3_result_error(ctx, "Ruby Exception occurred", -1); } - rb_sqlite3_aggregate_instance_destroy(ctx); + rb_sqlite3_aggregate_instance_destroy(ctx, call.agg); } /* call-seq: define_aggregator2(aggregator) @@ -294,4 +270,5 @@ rb_sqlite3_aggregator_init(void) rb_gc_register_mark_object(cAggregatorInstance); id_instances = rb_intern("-instances"); + id_handler_instance = rb_intern("-handler_instance"); } diff --git a/ext/sqlite3/backup.c b/ext/sqlite3/backup.c index b40b1403..f793ef53 100644 --- a/ext/sqlite3/backup.c +++ b/ext/sqlite3/backup.c @@ -113,11 +113,16 @@ static VALUE step(VALUE self, VALUE nPage) { sqlite3BackupRubyPtr ctx; - int status; + rb_sqlite3_callback_frame_t frame; + int pages, status, callback_state; TypedData_Get_Struct(self, sqlite3BackupRuby, &backup_type, ctx); REQUIRE_OPEN_BACKUP(ctx); - status = sqlite3_backup_step(ctx->p, NUM2INT(nPage)); + pages = NUM2INT(nPage); + + rb_sqlite3_push_callback_frame(&frame); + status = sqlite3_backup_step(ctx->p, pages); + if ((callback_state = rb_sqlite3_pop_callback_frame(&frame))) { rb_jump_tag(callback_state); } return INT2NUM(status); } diff --git a/ext/sqlite3/database.c b/ext/sqlite3/database.c index 1b23f366..7628f323 100644 --- a/ext/sqlite3/database.c +++ b/ext/sqlite3/database.c @@ -276,11 +276,21 @@ total_changes(VALUE self) return INT2NUM(sqlite3_total_changes(ctx->db)); } +static VALUE +tracefunc_protected(VALUE args_value) +{ + VALUE *args = (VALUE *)args_value; + return rb_funcall(args[0], rb_intern("call"), 1, rb_str_new2((const char *)args[1])); +} + static void tracefunc(void *data, const char *sql) { sqlite3RubyPtr ctx = (sqlite3RubyPtr)data; - rb_funcall(ctx->trace_handler, rb_intern("call"), 1, rb_str_new2(sql)); + VALUE args[] = { ctx->trace_handler, (VALUE)sql }; + int failed; + + rb_sqlite3_protect_callback(tracefunc_protected, (VALUE)args, &failed); } /* call-seq: @@ -311,15 +321,24 @@ trace(int argc, VALUE *argv, VALUE self) return self; } +static VALUE +rb_sqlite3_busy_handler_protected(VALUE args_value) +{ + VALUE *args = (VALUE *)args_value; + return rb_funcall(args[0], rb_intern("call"), 1, INT2NUM((int)args[1])); +} + static int rb_sqlite3_busy_handler(void *context, int count) { sqlite3RubyPtr ctx = (sqlite3RubyPtr)context; + VALUE args[] = { ctx->busy_handler, (VALUE)count }; + VALUE result; + int failed; - VALUE handle = ctx->busy_handler; - VALUE result = rb_funcall(handle, rb_intern("call"), 1, INT2NUM(count)); + result = rb_sqlite3_protect_callback(rb_sqlite3_busy_handler_protected, (VALUE)args, &failed); - if (Qfalse == result) { return 0; } + if (failed || Qfalse == result) { return 0; } return 1; } @@ -533,13 +552,13 @@ static void rb_sqlite3_func(sqlite3_context *ctx, int argc, sqlite3_value **argv) { rb_sqlite3_func_args_t args = { .ctx = ctx, .argc = argc, .argv = argv }; - int exc_status; + int failed; - rb_protect(rb_sqlite3_func_protected, (VALUE)&args, &exc_status); + rb_sqlite3_protect_callback(rb_sqlite3_func_protected, (VALUE)&args, &failed); - if (exc_status) { + if (failed) { /* the user should never see this message, because Statement#step will - * re-raise the exception still in rb_errinfo */ + * resume the Ruby exception instead */ sqlite3_result_error(ctx, "Ruby Exception occurred", -1); } } @@ -677,6 +696,33 @@ changes(VALUE self) return INT2NUM(sqlite3_changes(ctx->db)); } +typedef struct { + VALUE authorizer; + int action; + const char *a; + const char *b; + const char *c; + const char *d; +} rb_sqlite3_auth_args_t; + +static VALUE +rb_sqlite3_auth_protected(VALUE auth_args_value) +{ + rb_sqlite3_auth_args_t *args = (rb_sqlite3_auth_args_t *)auth_args_value; + VALUE action = INT2NUM(args->action); + VALUE a = args->a ? rb_str_new2(args->a) : Qnil; + VALUE b = args->b ? rb_str_new2(args->b) : Qnil; + VALUE c = args->c ? rb_str_new2(args->c) : Qnil; + VALUE d = args->d ? rb_str_new2(args->d) : Qnil; + VALUE result = rb_funcall(args->authorizer, rb_intern("call"), 5, action, a, b, c, d); + + if (T_FIXNUM == TYPE(result)) { return INT2FIX(NUM2INT(result)); } + if (Qtrue == result) { return INT2FIX(SQLITE_OK); } + if (Qfalse == result) { return INT2FIX(SQLITE_DENY); } + + return INT2FIX(SQLITE_IGNORE); +} + static int rb_sqlite3_auth( void *ctx, @@ -687,18 +733,17 @@ rb_sqlite3_auth( const char *_d) { sqlite3RubyPtr db_ctx = (sqlite3RubyPtr)ctx; - VALUE action = INT2NUM(_action); - VALUE a = _a ? rb_str_new2(_a) : Qnil; - VALUE b = _b ? rb_str_new2(_b) : Qnil; - VALUE c = _c ? rb_str_new2(_c) : Qnil; - VALUE d = _d ? rb_str_new2(_d) : Qnil; - VALUE result = rb_funcall(db_ctx->authorizer, rb_intern("call"), 5, action, a, b, c, d); - - if (T_FIXNUM == TYPE(result)) { return (int)NUM2INT(result); } - if (Qtrue == result) { return SQLITE_OK; } - if (Qfalse == result) { return SQLITE_DENY; } - - return SQLITE_IGNORE; + rb_sqlite3_auth_args_t args = { + .authorizer = db_ctx->authorizer, .action = _action, .a = _a, .b = _b, .c = _c, .d = _d + }; + VALUE result; + int failed; + + result = rb_sqlite3_protect_callback(rb_sqlite3_auth_protected, (VALUE)&args, &failed); + + if (failed) { return SQLITE_DENY; } + + return FIX2INT(result); } /* call-seq: set_authorizer = auth @@ -770,10 +815,19 @@ set_extended_result_codes(VALUE self, VALUE enable) return self; } -int -rb_comparator_func(void *ctx, int a_len, const void *a, int b_len, const void *b) -{ +typedef struct { VALUE comparator; + int a_len; + const void *a; + int b_len; + const void *b; + int comparison; +} rb_comparator_args_t; + +static VALUE +rb_comparator_protected(VALUE comparator_args_value) +{ + rb_comparator_args_t *args = (rb_comparator_args_t *)comparator_args_value; VALUE a_str; VALUE b_str; VALUE comparison; @@ -781,9 +835,8 @@ rb_comparator_func(void *ctx, int a_len, const void *a, int b_len, const void *b internal_encoding = rb_default_internal_encoding(); - comparator = (VALUE)ctx; - a_str = rb_str_new((const char *)a, a_len); - b_str = rb_str_new((const char *)b, b_len); + a_str = rb_str_new((const char *)args->a, args->a_len); + b_str = rb_str_new((const char *)args->b, args->b_len); rb_enc_associate_index(a_str, rb_utf8_encindex()); rb_enc_associate_index(b_str, rb_utf8_encindex()); @@ -793,9 +846,23 @@ rb_comparator_func(void *ctx, int a_len, const void *a, int b_len, const void *b b_str = rb_str_export_to_enc(b_str, internal_encoding); } - comparison = rb_funcall(comparator, rb_intern("compare"), 2, a_str, b_str); + comparison = rb_funcall(args->comparator, rb_intern("compare"), 2, a_str, b_str); + + args->comparison = NUM2INT(comparison); - return NUM2INT(comparison); + return Qnil; +} + +int +rb_comparator_func(void *ctx, int a_len, const void *a, int b_len, const void *b) +{ + rb_comparator_args_t args = { .comparator = (VALUE)ctx, .a_len = a_len, .a = a, .b_len = b_len, .b = b }; + int failed; + + rb_sqlite3_protect_callback(rb_comparator_protected, (VALUE)&args, &failed); + + /* sqlite has no way to report a comparator error */ + return failed ? 0 : args.comparison; } static int @@ -917,44 +984,68 @@ transaction_active_p(VALUE self) return sqlite3_get_autocommit(ctx->db) ? Qfalse : Qtrue; } -static int -hash_callback_function(void *data_ptr, int count, char **data, char **columns) +typedef struct { + VALUE callback_ary; + VALUE (*build_row)(VALUE); +} rb_sqlite3_exec_batch_t; + +typedef struct { + VALUE callback_ary; + int count; + char **data; + char **columns; +} rb_sqlite3_exec_row_t; + +static VALUE +hash_callback_protected(VALUE row_value) { - VALUE callback_ary = (VALUE)data_ptr; + rb_sqlite3_exec_row_t *row = (rb_sqlite3_exec_row_t *)row_value; VALUE new_hash = rb_hash_new(); int i; - for (i = 0; i < count; i++) { - if (data[i] == NULL) { - rb_hash_aset(new_hash, rb_str_new_cstr(columns[i]), Qnil); + for (i = 0; i < row->count; i++) { + if (row->data[i] == NULL) { + rb_hash_aset(new_hash, rb_str_new_cstr(row->columns[i]), Qnil); } else { - rb_hash_aset(new_hash, rb_str_new_cstr(columns[i]), rb_str_new_cstr(data[i])); + rb_hash_aset(new_hash, rb_str_new_cstr(row->columns[i]), rb_str_new_cstr(row->data[i])); } } - rb_ary_push(callback_ary, new_hash); + rb_ary_push(row->callback_ary, new_hash); - return 0; + return Qnil; } -static int -regular_callback_function(void *data_ptr, int count, char **data, char **columns) +static VALUE +regular_callback_protected(VALUE row_value) { - VALUE callback_ary = (VALUE)data_ptr; + rb_sqlite3_exec_row_t *row = (rb_sqlite3_exec_row_t *)row_value; VALUE new_ary = rb_ary_new(); int i; - for (i = 0; i < count; i++) { - if (data[i] == NULL) { + for (i = 0; i < row->count; i++) { + if (row->data[i] == NULL) { rb_ary_push(new_ary, Qnil); } else { - rb_ary_push(new_ary, rb_str_new_cstr(data[i])); + rb_ary_push(new_ary, rb_str_new_cstr(row->data[i])); } } - rb_ary_push(callback_ary, new_ary); + rb_ary_push(row->callback_ary, new_ary); - return 0; + return Qnil; +} + +static int +exec_batch_callback_function(void *batch_ptr, int count, char **data, char **columns) +{ + rb_sqlite3_exec_batch_t *batch = (rb_sqlite3_exec_batch_t *)batch_ptr; + rb_sqlite3_exec_row_t row = { .callback_ary = batch->callback_ary, .count = count, .data = data, .columns = columns }; + int failed; + + rb_sqlite3_protect_callback(batch->build_row, (VALUE)&row, &failed); + + return failed; } @@ -972,21 +1063,27 @@ exec_batch(VALUE self, VALUE sql, VALUE results_as_hash) sqlite3RubyPtr ctx; int status; VALUE callback_ary = rb_ary_new(); + rb_sqlite3_exec_batch_t batch = { + .callback_ary = callback_ary, + .build_row = results_as_hash == Qtrue ? hash_callback_protected : regular_callback_protected + }; + rb_sqlite3_callback_frame_t frame; + int callback_state; + const char *sql_cstr; char *errMsg; TypedData_Get_Struct(self, sqlite3Ruby, &database_type, ctx); REQUIRE_OPEN_DB(ctx); + sql_cstr = StringValuePtr(sql); timespecclear(&ctx->stmt_deadline); - if (results_as_hash == Qtrue) { - status = sqlite3_exec(ctx->db, StringValuePtr(sql), hash_callback_function, - (void *)callback_ary, - &errMsg); - } else { - status = sqlite3_exec(ctx->db, StringValuePtr(sql), regular_callback_function, - (void *)callback_ary, - &errMsg); + rb_sqlite3_push_callback_frame(&frame); + status = sqlite3_exec(ctx->db, sql_cstr, exec_batch_callback_function, (void *)&batch, &errMsg); + + if ((callback_state = rb_sqlite3_pop_callback_frame(&frame))) { + sqlite3_free(errMsg); + rb_jump_tag(callback_state); } CHECK_MSG(ctx->db, status, errMsg); diff --git a/ext/sqlite3/exception.c b/ext/sqlite3/exception.c index c7686189..40abbbb7 100644 --- a/ext/sqlite3/exception.c +++ b/ext/sqlite3/exception.c @@ -120,3 +120,98 @@ rb_sqlite3_raise_with_sql(sqlite3 *db, int status, const char *sql) rb_exc_raise(exception); } + +/* Each call into sqlite that can run callbacks pushes a frame on the current + * fiber's chain. A callback that fails records its rb_protect state in the top + * frame, and the call resumes Ruby's pending jump once sqlite returns. Ruby + * code that runs in between, such as a finalizer during an allocation, pushes + * its own frame for any nested call, so it never takes the outer call's jump. + * The chain is fiber-local because Ruby code inside a callback can switch + * threads or fibers, and a Ruby thread can move between native threads inside + * rb_protect. */ +typedef struct { + rb_sqlite3_callback_frame_t *top; +} callback_frames_t; + +static const rb_data_type_t callback_frames_type = { + .wrap_struct_name = "SQLite3::CallbackFrames", + .function = { + .dfree = RUBY_TYPED_DEFAULT_FREE, + }, + .flags = RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_WB_PROTECTED, +}; + +static ID id_callback_frames; + +/* Ruby code can overwrite or delete the fiber-local, so check its type. */ +static VALUE +current_callback_frames_holder(void) +{ + VALUE holder = rb_thread_local_aref(rb_thread_current(), id_callback_frames); + return rb_typeddata_is_kind_of(holder, &callback_frames_type) ? holder : Qnil; +} + +static callback_frames_t * +current_callback_frames(void) +{ + VALUE holder = current_callback_frames_holder(); + return NIL_P(holder) ? NULL : (callback_frames_t *)RTYPEDDATA_DATA(holder); +} + +void +rb_sqlite3_push_callback_frame(rb_sqlite3_callback_frame_t *frame) +{ + VALUE holder = current_callback_frames_holder(); + callback_frames_t *frames; + + if (NIL_P(holder)) { + holder = TypedData_Make_Struct(rb_cObject, callback_frames_t, &callback_frames_type, frames); + rb_thread_local_aset(rb_thread_current(), id_callback_frames, holder); + } + frames = (callback_frames_t *)RTYPEDDATA_DATA(holder); + + frame->state = 0; + frame->prev = frames->top; + frame->holder = holder; + frames->top = frame; +} + +int +rb_sqlite3_pop_callback_frame(rb_sqlite3_callback_frame_t *frame) +{ + ((callback_frames_t *)RTYPEDDATA_DATA(frame->holder))->top = frame->prev; + return frame->state; +} + +VALUE +rb_sqlite3_protect_callback(VALUE (*body)(VALUE), VALUE data, int *failed) +{ + callback_frames_t *frames = current_callback_frames(); + rb_sqlite3_callback_frame_t *frame = frames ? frames->top : NULL; + VALUE result; + int state; + + if (frame && frame->state) { + *failed = 1; + return Qnil; + } + + result = rb_protect(body, data, &state); + *failed = state != 0; + + if (frame) { + frame->state = state; + } else if (state) { + /* sqlite ran the callback from a call that cannot resume the jump, such + * as Statement#reset!, so the error is dropped */ + rb_set_errinfo(Qnil); + } + + return result; +} + +void +init_sqlite3_exception(void) +{ + id_callback_frames = rb_intern("__sqlite3_callback_frames__"); +} diff --git a/ext/sqlite3/exception.h b/ext/sqlite3/exception.h index f51c80c0..30235629 100644 --- a/ext/sqlite3/exception.h +++ b/ext/sqlite3/exception.h @@ -9,4 +9,25 @@ void rb_sqlite3_raise(sqlite3 *db, int status); void rb_sqlite3_raise_msg(sqlite3 *db, int status, const char *msg); void rb_sqlite3_raise_with_sql(sqlite3 *db, int status, const char *sql); +typedef struct rb_sqlite3_callback_frame { + int state; + struct rb_sqlite3_callback_frame *prev; + /* the frame lives on the C stack, so GC marks and pins this */ + VALUE holder; +} rb_sqlite3_callback_frame_t; + +/* A call into sqlite that can run callbacks is wrapped in a frame. sqlite + * cannot carry a raise or a throw, so a failed callback records its rb_protect + * state in the frame, and later callbacks skip their Ruby code. Pop the frame + * when sqlite returns, then resume the jump with rb_jump_tag if the returned + * state is nonzero. Run nothing but the sqlite call in between, because a + * raise there would skip the pop. */ +void rb_sqlite3_push_callback_frame(rb_sqlite3_callback_frame_t *frame); +int rb_sqlite3_pop_callback_frame(rb_sqlite3_callback_frame_t *frame); + +/* Runs a callback's Ruby code for sqlite. Sets *failed if it did not complete. */ +VALUE rb_sqlite3_protect_callback(VALUE (*body)(VALUE), VALUE data, int *failed); + +void init_sqlite3_exception(void); + #endif diff --git a/ext/sqlite3/sqlite3.c b/ext/sqlite3/sqlite3.c index dd118e56..3e506d19 100644 --- a/ext/sqlite3/sqlite3.c +++ b/ext/sqlite3/sqlite3.c @@ -204,6 +204,7 @@ Init_sqlite3_native(void) sqlite3_initialize(); #endif + init_sqlite3_exception(); init_sqlite3_constants(); init_sqlite3_database(); init_sqlite3_statement(); diff --git a/ext/sqlite3/statement.c b/ext/sqlite3/statement.c index bf92beed..258a78b4 100644 --- a/ext/sqlite3/statement.c +++ b/ext/sqlite3/statement.c @@ -54,8 +54,9 @@ prepare(VALUE self, VALUE db, VALUE sql) { sqlite3RubyPtr db_ctx = sqlite3_database_unwrap(db); sqlite3StmtRubyPtr ctx; - const char *tail = NULL; - int status; + const char *sql_cstr, *tail = NULL; + rb_sqlite3_callback_frame_t frame; + int sql_len, status, callback_state; StringValue(sql); @@ -68,17 +69,22 @@ prepare(VALUE self, VALUE db, VALUE sql) timespecclear(&db_ctx->stmt_deadline); + sql_cstr = StringValuePtr(sql); + sql_len = RSTRING_LENINT(sql); + + rb_sqlite3_push_callback_frame(&frame); #ifdef HAVE_SQLITE3_PREPARE_V2 status = sqlite3_prepare_v2( #else status = sqlite3_prepare( #endif db_ctx->db, - (const char *)StringValuePtr(sql), - RSTRING_LENINT(sql), + sql_cstr, + sql_len, &ctx->st, &tail ); + if ((callback_state = rb_sqlite3_pop_callback_frame(&frame))) { rb_jump_tag(callback_state); } CHECK_PREPARE(db_ctx->db, status, StringValuePtr(sql)); @@ -125,7 +131,8 @@ step(VALUE self) { sqlite3StmtRubyPtr ctx; sqlite3_stmt *stmt; - int value, length; + rb_sqlite3_callback_frame_t frame; + int value, length, callback_state; VALUE list; rb_encoding *internal_encoding; @@ -144,15 +151,9 @@ step(VALUE self) timespecclear(&ctx->db->stmt_deadline); } + rb_sqlite3_push_callback_frame(&frame); value = sqlite3_step(stmt); - if (rb_errinfo() != Qnil) { - /* some user defined function was invoked as a callback during step and - * it raised an exception that has been suppressed until step returns. - * Now re-raise it. */ - VALUE exception = rb_errinfo(); - rb_set_errinfo(Qnil); - rb_exc_raise(exception); - } + if ((callback_state = rb_sqlite3_pop_callback_frame(&frame))) { rb_jump_tag(callback_state); } if (value == SQLITE_DONE) { ctx->done_p = 1; diff --git a/test/helper.rb b/test/helper.rb index d785f3c2..5a4f8888 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -126,6 +126,38 @@ def skip_if_timing_unreliable skip("GC stress is too slow to measure throughput") if gc_level == :stress end + # A callback that raises through sqlite leaves the connection's mutex locked + # by the raising thread, and a second thread that then uses the connection + # blocks forever while holding the GVL. The block makes a callback raise + # and returns its connection. + def assert_connection_usable_from_another_thread + skip("interpreter doesn't support fork") unless Process.respond_to?(:fork) + skip("valgrind doesn't handle forking") if i_am_running_in_valgrind + + read, write = IO.pipe + old_stderr, $stderr = $stderr, StringIO.new + pid = Process.fork do + read.close + begin + db = yield + Thread.new { db.execute("select 1") }.join + write.write("ok") + rescue Exception => e # rubocop:disable Lint/RescueException + write.write("#{e.class}: #{e.message}") + end + exit! + end + $stderr = old_stderr + write.close + + result = IO.select([read], nil, nil, 10) && read.read + Process.kill(:KILL, pid) unless result + Process.waitpid(pid) + read.close + + assert_equal("ok", result || "second thread deadlocked on the connection") + end + def windows? ::RUBY_PLATFORM =~ /mingw|mswin/ end diff --git a/test/test_backup.rb b/test/test_backup.rb index c92d7b59..58bb91a4 100644 --- a/test/test_backup.rb +++ b/test/test_backup.rb @@ -30,6 +30,28 @@ def test_backup_all b.finish assert_equal(@data.length, @ddb.execute("SELECT * FROM foo;").length) end + + def test_backup_step_raises_the_busy_handler_exception + Dir.mktmpdir do |dir| + path = File.join(dir, "source.db") + source = SQLite3::Database.new(path) + source.execute("create table foo (a)") + locker = SQLite3::Database.new(path) + locker.execute("begin exclusive") + source.busy_handler { |count| raise "busy" } + backup = SQLite3::Backup.new(@ddb, "main", source, "main") + + error = assert_raise(RuntimeError) { backup.step(-1) } + assert_equal("busy", error.message) + + backup.finish + locker.execute("rollback") + refute_predicate(locker, :transaction_active?) + ensure + locker&.close + source&.close + end + end end end end diff --git a/test/test_collation.rb b/test/test_collation.rb index 6c919b3c..667114c4 100644 --- a/test/test_collation.rb +++ b/test/test_collation.rb @@ -40,6 +40,30 @@ def test_custom_collation assert_equal 1, comparator.calls.length end + def test_collation_raise_propagates_and_connection_remains_usable + @db.collation "boom", Class.new { def compare(left, right) = raise("boom") }.new + + error = assert_raise(RuntimeError) { @db.execute("select data from ex order by 1 collate boom") } + assert_equal("boom", error.message) + + assert_equal([[2]], @db.execute("select 1 + 1")) + end + + def test_collation_result_keeps_its_sign_outside_the_fixnum_range + @db.collation "wide", Class.new { def compare(left, right) = (left <=> right) * 2**30 }.new + + assert_equal([[1, 0]], @db.execute("select 'world' > 'hello' collate wide, 'world' < 'hello' collate wide")) + end + + def test_collation_raise_does_not_deadlock_other_threads_using_the_connection + assert_connection_usable_from_another_thread do + db = SQLite3::Database.new(":memory:") + db.collation "boom", Class.new { def compare(left, right) = raise("boom") }.new + assert_raise(RuntimeError) { db.execute("select column1 from (values ('a'), ('b')) order by 1 collate boom") } + db + end + end + def test_collation_does_not_use_moved_comparator_after_gc_compaction skip_unless_compaction_supported diff --git a/test/test_database.rb b/test/test_database.rb index 7d469520..d968b961 100644 --- a/test/test_database.rb +++ b/test/test_database.rb @@ -648,6 +648,133 @@ def test_function_raise_does_not_deadlock_other_threads_using_the_connection assert_equal("ok", result, "second thread deadlocked on the connection after a function raised") end + def test_function_raise_propagates_from_execute_batch2 + @db.define_function("boom") { |a| raise "boom: #{a}" } + + error = assert_raise(RuntimeError) { @db.execute_batch2("select boom(1)") } + assert_equal("boom: 1", error.message) + + assert_equal([[2]], @db.execute("select 1 + 1")) + end + + def test_trace_raise_propagates_and_connection_remains_usable + @db.trace { |sql| raise "trace: #{sql}" } + + error = assert_raise(RuntimeError) { @db.execute("select 1") } + assert_equal("trace: select 1", error.message) + + @db.trace(nil) + assert_equal([[2]], @db.execute("select 1 + 1")) + end + + def test_trace_raise_propagates_from_execute_batch2 + @db.trace { |sql| raise "trace: #{sql}" } + + error = assert_raise(RuntimeError) { @db.execute_batch2("select 1") } + assert_equal("trace: select 1", error.message) + + @db.trace(nil) + assert_equal([[2]], @db.execute("select 1 + 1")) + end + + def test_trace_throw_unwinds_to_its_catch + @db.trace { |sql| throw :traced, sql } + + assert_equal("select 1", catch(:traced) { @db.execute("select 1") }) + + @db.trace(nil) + assert_equal([[2]], @db.execute("select 1 + 1")) + end + + def test_function_throw_unwinds_to_its_catch + @db.define_function("boom") { |a| throw :boom, a } + + assert_equal(1, catch(:boom) { @db.execute("select boom(1)") }) + + assert_equal([[2]], @db.execute("select 1 + 1")) + end + + def test_trace_raise_is_not_taken_by_a_query_in_a_finalizer + skip("GC.stress is too slow under valgrind") if i_am_running_in_valgrind + skip("finalizers run on another thread on non-CRuby") unless RUBY_ENGINE == "ruby" + + probe = SQLite3::Database.new(":memory:").prepare("select 2") + probe_results = [] + aggregator = Class.new do + def step(*) = nil + def finalize = 1 + end + @db.define_aggregator("agg", aggregator.new) + finalizer = proc { probe_results << probe.reset!.step } + holder = Thread.new do + Array.new(10) { Object.new.tap { |object| ObjectSpace.define_finalizer(object, finalizer) } } + end.value + @db.trace do + finalized = holder.dup + holder.clear + raise "trace" + ensure + finalized.size + end + + error = begin + GC.stress = true + assert_raise(RuntimeError) { @db.execute("select agg(1), agg(2)") } + ensure + GC.stress = false + end + + assert_equal("trace", error.message) + GC.start + assert_equal(Array.new(10) { [2] }, probe_results) + end + + def test_trace_that_deletes_fiber_locals_does_not_break_the_query + @db.trace { Thread.current.keys.each { |key| Thread.current[key] = nil } } + + assert_equal([[1]], @db.execute("select 1")) + end + + def test_trace_raise_does_not_deadlock_other_threads_using_the_connection + assert_connection_usable_from_another_thread do + db = SQLite3::Database.new(":memory:") + db.trace { |sql| raise "trace" } + assert_raise(RuntimeError) { db.execute("select 1") } + db.trace(nil) + db + end + end + + def test_authorizer_raise_propagates_and_connection_remains_usable + @db.authorizer = proc { |*| raise "authorizer" } + + error = assert_raise(RuntimeError) { @db.execute("select 1") } + assert_equal("authorizer", error.message) + + @db.authorizer = nil + assert_equal([[2]], @db.execute("select 1 + 1")) + end + + def test_authorizer_raise_propagates_from_execute_batch2 + @db.authorizer = proc { |*| raise "authorizer" } + + error = assert_raise(RuntimeError) { @db.execute_batch2("select 1") } + assert_equal("authorizer", error.message) + + @db.authorizer = nil + assert_equal([[2]], @db.execute("select 1 + 1")) + end + + def test_authorizer_raise_does_not_deadlock_other_threads_using_the_connection + assert_connection_usable_from_another_thread do + db = SQLite3::Database.new(":memory:") + db.authorizer = proc { |*| raise "authorizer" } + assert_raise(RuntimeError) { db.execute("select 1") } + db.authorizer = nil + db + end + end + def test_function_gc_segfault @db.create_function("bug", -1) { |func, *values| func.result = values.join } # With a lot of data and a lot of threads, try to induce a GC segfault. diff --git a/test/test_integration_aggregate.rb b/test/test_integration_aggregate.rb index dbcdf915..f3d571ea 100644 --- a/test/test_integration_aggregate.rb +++ b/test/test_integration_aggregate.rb @@ -466,6 +466,40 @@ def step(*args) assert_equal 1, ObjectSpace.each_object(aggregator).count end + def test_aggregate_throw_unwinds_to_its_catch + aggregator = Class.new(AccumulateAggregator) do + def step(*args) + throw :aggregated, args + end + end + @db.define_aggregator("accumulate", aggregator.new) + + assert_equal([10], catch(:aggregated) { @db.execute("select accumulate(c) from foo") }) + + assert_equal([[2]], @db.execute("select 1 + 1")) + end + + def test_aggregate_raise_outside_the_handler_does_not_deadlock_other_threads_using_the_connection + skip("TracePoint is CRuby-specific here") unless RUBY_ENGINE == "ruby" + + assert_connection_usable_from_another_thread do + db = SQLite3::Database.new(":memory:") + handler = Class.new(AccumulateAggregator) + db.define_aggregator("accumulate", handler.new) + interrupt = TracePoint.new(:c_call) do |tp| + next unless tp.method_id == :initialize && !tp.self.is_a?(handler) + interrupt.disable + raise "interrupt" + end + db.trace { interrupt.enable(target_thread: Thread.current) } + + error = assert_raise(RuntimeError) { db.execute("select accumulate(1)") } + assert_equal("interrupt", error.message) + db.trace(nil) + db + end + end + def test_aggregate_instance_is_released_after_an_interrupt db = @db aggregator = Class.new(AccumulateAggregator) do diff --git a/test/test_integration_pending.rb b/test/test_integration_pending.rb index f42b06ef..3ed55bc0 100644 --- a/test/test_integration_pending.rb +++ b/test/test_integration_pending.rb @@ -54,6 +54,32 @@ def teardown File.delete("test.db") end + def test_busy_handler_raise_propagates_and_connection_remains_usable + @db.execute("begin immediate") + db = SQLite3::Database.new("test.db") + db.busy_handler { |count| raise "busy: #{count}" } + + error = assert_raise(RuntimeError) { db.execute("begin immediate") } + assert_equal("busy: 0", error.message) + + @db.execute("rollback") + db.busy_handler(nil) + assert_equal([[2]], db.execute("select 1 + 1")) + ensure + db&.close + end + + def test_busy_handler_raise_does_not_deadlock_other_threads_using_the_connection + assert_connection_usable_from_another_thread do + @locker = SQLite3::Database.new("test.db") + @locker.execute("begin immediate") + db = SQLite3::Database.new("test.db") + db.busy_handler { |count| raise "busy" } + assert_raise(RuntimeError) { db.execute("begin immediate") } + db + end + end + def test_busy_handler_impatient synchronizer = ThreadSynchronizer.new handler_call_count = 0 From 60bf60e655f6ddc04f97135443c1c1b013428ac7 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 26 Oct 2026 15:56:12 -0400 Subject: [PATCH 11/12] doc: list the callback behavior changes in CHANGELOG --- CHANGELOG.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d94ef83b..bdd1ad6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,24 @@ - Raise `RangeError` when a SQL string, a bound String or Blob value, or a String or Blob returned from a custom function or aggregate is longer than `INT_MAX` bytes. Previously the length was truncated to 32 bits, so SQLite could read only part of the string and drop trailing clauses from a query. See [GHSA-4fqr-96q8-xrfx](https://github.com/sparklemotion/sqlite3-ruby/security/advisories/GHSA-4fqr-96q8-xrfx) for more information. #762 @flavorjones - Fix a crash when a custom aggregate's `finalize` returns a value that can't be converted to a SQLite result. The exception is now raised from the query and the connection remains usable. #762 @flavorjones -- Fix a use-after-free when the hash returned by `Database#collations` is cleared, modified or frozen. GC could free a comparator that SQLite still called. #764 @flavorjones -- Fix a deadlock when a collation comparator, `trace` block, `busy_handler` block or authorizer raises, or when an interrupt such as `Thread#raise` arrives while an aggregate's handler is being created. Another thread that used the connection blocked forever. The exception is now raised from the query and the connection remains usable. #764 @flavorjones -- Fix a crash when a custom function or aggregate uses `throw`. #764 @flavorjones -- `Database#execute_batch2` now raises the exception from a custom function, instead of `SQLite3::SQLException` "Ruby Exception occurred". #764 @flavorjones +- Fix a use-after-free when the hash returned by `Database#collations` is cleared, modified or frozen. GC could free a comparator that SQLite still called. When that hash is frozen, `Database#collation` still raises `FrozenError`, but the new comparator stays registered and in use. #764 @flavorjones +- Fix a deadlock when a collation comparator, `trace` block, `busy_handler` block or authorizer raises, or when an interrupt such as `Thread#raise` arrives while an aggregate's handler is being created. Another thread that used the connection blocked forever. The exception is now raised from the query and the connection remains usable. See "Changed" below for what SQLite does before the exception is raised. #764 @flavorjones +- Fix a crash when a custom function or aggregate uses `throw`. The throw now unwinds to its `catch`. #764 @flavorjones + +### Changed + +- When a callback raises or throws, SQLite now finishes its current call before the exception is raised, instead of being unwound in the middle of it. #764 @flavorjones + - When a `trace` block raises, SQLite runs the statement up to its first row, or to completion, before the exception is raised. Previously the statement stopped where the block raised. For example, an `INSERT` whose `trace` block raises now inserts its row. + - The other callbacks in that SQLite call skip their Ruby code: collation comparators return 0, functions and aggregates report an error to SQLite, the busy handler stops retrying and the authorizer denies. + - `Database#execute_batch2` now raises the exception from a custom function, instead of `SQLite3::SQLException` "Ruby Exception occurred". + - An exception from a callback that SQLite runs during `Statement#reset!` or `Database#close` is discarded. +- The extension keeps per-fiber state in `Thread.current[:__sqlite3_callback_frames__]`. A query raises `FrozenError` on a `Thread` that was frozen before its first query. #764 @flavorjones +- When SQLite can't allocate an aggregate's context, the query raises `SQLite3::MemoryException` instead of aborting the process. #764 @flavorjones ### Improved - `Statement#step` no longer allocates and discards an array when the statement has no more rows. This saves one object per statement execution. #758 @thomasklemm +- `ObjectSpace.dump` and heap profilers now report `SQLite3::Database` and `SQLite3::Statement` objects under their own type names, instead of `SQLite3::Backup`. #764 @flavorjones ### Documentation From 8e81efac697ca90ab85f944b0dff013614cd9e23 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 26 Oct 2026 16:07:47 -0400 Subject: [PATCH 12/12] test: stop counting the finalizer probes On x86-linux-musl, conservative stack scanning kept one of the ten probe objects alive, so only nine finalizers ran. Assert that some probes ran and that each returned `[2]`. --- test/test_database.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/test_database.rb b/test/test_database.rb index d968b961..ec59ae8a 100644 --- a/test/test_database.rb +++ b/test/test_database.rb @@ -726,7 +726,8 @@ def finalize = 1 assert_equal("trace", error.message) GC.start - assert_equal(Array.new(10) { [2] }, probe_results) + refute_empty(probe_results) + assert_equal([[2]], probe_results.uniq) end def test_trace_that_deletes_fiber_locals_does_not_break_the_query