From ef7977c4aa7c5904188d4fc0d606afe322ec7d39 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 6 Aug 2026 10:50:28 -0400 Subject: [PATCH 1/4] Add SQLITE3_TEST_GC_LEVEL to the test suite The test suite ran only with the default GC behavior. Some classes of memory bugs need a GC cycle, GC compaction, or GC stress mode before they show themselves, and there was no way to ask for any of that while running the tests. `SQLITE3_TEST_GC_LEVEL`, ported from nokogiri's `NOKOGIRI_TEST_GC_LEVEL`, will choose how hard the suite leans on the GC. `normal` will keep the current behavior, `minor` and `major` will run a GC cycle after every test, `compact` and `verify` will also compact the heap and check the references afterwards, and `stress` will run every test with `GC.stress` set. The chosen level will apply to every test in the suite, and a level that the platform cannot support will fall back to `normal`. One test that measures throughput will be skipped under valgrind and under GC stress mode, where the environment is too slow to meet its bar. `CONTRIBUTING.md` will document the levels in a new section on debugging memory issues. --- CONTRIBUTING.md | 47 +++++++++++++++ test/helper.rb | 99 ++++++++++++++++++++++++++++++++ test/test_integration_pending.rb | 2 + 3 files changed, 148 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2dea3def..969e1eb6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,53 @@ explicitly closed, it will be closed when it is GCed. will be closed/finalized when it is GCed. +## Debugging memory issues + +Please install `valgrind` and `gdb` before you use the tools in this section. + +Run the test suite under valgrind and [`ruby_memcheck`](https://github.com/Shopify/ruby_memcheck) to +look for memory leaks and other memory errors: + +``` sh +bundle exec rake compile test:valgrind +``` + +Run the test suite in the debugger: + +``` sh +bundle exec rake compile test:gdb +``` + +You can also run the test suite with a variety of GC behaviors, which is useful to localize some +classes of memory bugs. Set the `SQLITE3_TEST_GC_LEVEL` environment variable (see `test/helper.rb` +for more info). A more stressful level finds more bugs, but makes the suite slower: + +``` sh +# ordinary GC behavior (the default) +SQLITE3_TEST_GC_LEVEL=normal bundle exec rake compile test + +# minor GC after each test +SQLITE3_TEST_GC_LEVEL=minor bundle exec rake compile test + +# major GC after each test +SQLITE3_TEST_GC_LEVEL=major bundle exec rake compile test + +# major GC after each test, and GC compaction after every 20 tests +SQLITE3_TEST_GC_LEVEL=compact bundle exec rake compile test + +# verify references after compaction, after every 20 tests +# (see https://alanwu.space/post/check-compaction/) +SQLITE3_TEST_GC_LEVEL=verify bundle exec rake compile test + +# run each test with GC "stress mode" on +SQLITE3_TEST_GC_LEVEL=stress bundle exec rake compile test +``` + +The `compact` and `verify` levels fall back to `normal` on a platform that does not support GC +compaction. The `stress` level makes the suite about 150 times slower, and it makes +timing-sensitive tests unreliable. + + ## Building gems As a prerequisite please make sure you have `docker` correctly installed, so that you're able to cross-compile the native gems. diff --git a/test/helper.rb b/test/helper.rb index 39a1b2b9..e0677db6 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -1,3 +1,15 @@ +# +# Some environment variables that are used to configure the test suite: +# +# - SQLITE3_TEST_GC_LEVEL: (roughly in order of stress) +# - "normal" - normal GC behavior (default) +# - "minor" - force a minor GC cycle after each test +# - "major" - force a major GC cycle after each test +# - "compact" - force a major GC after each test, and GC compaction after every 20 tests +# - "verify" - force a major GC after each test, and verify references-after-compaction after +# every 20 tests +# - "stress" - run each test with GC.stress set to true +# require "sqlite3" require "minitest/autorun" require "yaml" @@ -6,10 +18,90 @@ module SQLite3 class TestCase < Minitest::Test + COMPACT_EVERY = 20 + + class << self + attr_accessor :test_count + + def gc_level + @gc_level ||= detect_gc_level + end + + private + + def detect_gc_level + case ENV["SQLITE3_TEST_GC_LEVEL"]&.to_sym + when :stress then :stress + when :minor then :minor + when :major then :major + when :compact then gc_compaction_supported? ? :compact : :normal + when :verify then gc_compaction_verifiable? ? :verify : :normal + else :normal + end + end + + def gc_compaction_supported? + # the only way to detect an unsupported platform is to try GC compaction + GC.compact + true + rescue NotImplementedError + 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 + alias_method :assert_not_equal, :refute_equal alias_method :assert_not_nil, :refute_nil alias_method :assert_raise, :assert_raises + def before_setup + TestCase.test_count += 1 + GC.stress = true if gc_level == :stress + + super + end + + def after_teardown + case gc_level + when :minor + GC.start(full_mark: false) + when :major + GC.start(full_mark: true) + when :compact + if compaction_scheduled? + GC.compact + putc("<") + else + GC.start(full_mark: true) + end + when :verify + if compaction_scheduled? + # https://alanwu.space/post/check-compaction/ + GC.verify_compaction_references(expand_heap: true, toward: :empty) + putc("!") + end + GC.start(full_mark: true) + when :stress + GC.stress = false + end + + super + end + + def gc_level + TestCase.gc_level + end + + def compaction_scheduled? + TestCase.test_count % COMPACT_EVERY == 0 + end + def assert_nothing_raised yield end @@ -19,8 +111,15 @@ def i_am_running_in_valgrind ENV["LD_PRELOAD"] =~ /valgrind|vgpreload/ end + def skip_if_timing_unreliable + skip("valgrind is too slow to measure throughput") if i_am_running_in_valgrind + skip("GC stress is too slow to measure throughput") if gc_level == :stress + end + def windows? ::RUBY_PLATFORM =~ /mingw|mswin/ end end end + +puts "SQLITE3_TEST_GC_LEVEL: #{SQLite3::TestCase.gc_level}" diff --git a/test/test_integration_pending.rb b/test/test_integration_pending.rb index 0b40a37f..f42b06ef 100644 --- a/test/test_integration_pending.rb +++ b/test/test_integration_pending.rb @@ -114,6 +114,8 @@ def test_busy_timeout end def test_busy_handler_timeout_releases_gvl + skip_if_timing_unreliable + @db.busy_handler_timeout = 100 t1sync = ThreadSynchronizer.new From 371fdc9e49369038a3073860508894f7b9fea90d Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 6 Aug 2026 11:25:50 -0400 Subject: [PATCH 2/4] Add a development docker image that contains valgrind Running the test suite under valgrind required valgrind on the host system, and not every developer can install it. The `docker:dev` rake tasks will build an image that contains valgrind, then run the test suite in a container against the working copy. The image is a development tool, so it is not part of the gem package. --- CONTRIBUTING.md | 18 +++++++++++++++++ misc/Dockerfile.dev | 15 ++++++++++++++ rakelib/check-manifest.rake | 3 +++ rakelib/docker-dev.rake | 39 +++++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+) create mode 100644 misc/Dockerfile.dev create mode 100644 rakelib/docker-dev.rake diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 969e1eb6..8c410010 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,24 @@ look for memory leaks and other memory errors: bundle exec rake compile test:valgrind ``` +If you can't install valgrind on your system, use the `sqlite3-dev` docker image, which contains +valgrind: + +``` sh +# build the image +bundle exec rake docker:dev:build + +# run the test suite in a container +bundle exec rake docker:dev:test + +# run the test suite under valgrind in a container +bundle exec rake docker:dev:test:valgrind +``` + +Each `docker:dev:test` task builds the image first, then mounts your working copy at `/sqlite3` in +the container. Note that the container compiles into the mounted working copy, so please re-run +`rake compile` on your machine afterwards. + Run the test suite in the debugger: ``` sh diff --git a/misc/Dockerfile.dev b/misc/Dockerfile.dev new file mode 100644 index 00000000..fc6c0ac9 --- /dev/null +++ b/misc/Dockerfile.dev @@ -0,0 +1,15 @@ +FROM ruby:4.0 + +ARG DEBIAN_FRONTEND=noninteractive +RUN apt-get update && \ + apt-get upgrade -y && \ + apt-get install -y apt-utils + +RUN apt-get install -y valgrind + +COPY Gemfile system/ +COPY Gemfile.lock system/ +COPY sqlite3.gemspec system/ + +RUN gem install bundler -v "$(grep -A 1 "BUNDLED WITH" system/Gemfile.lock | tail -n 1)" +RUN cd system && bundle install diff --git a/rakelib/check-manifest.rake b/rakelib/check-manifest.rake index 65f9bfbe..38bf3243 100644 --- a/rakelib/check-manifest.rake +++ b/rakelib/check-manifest.rake @@ -15,6 +15,7 @@ task :check_manifest do doc gems issues + misc patches pkg ports @@ -26,11 +27,13 @@ task :check_manifest do } ignore_files = %w[ .editorconfig + .git .gitignore .rdoc_options .rubocop.yml Gemfile* Rakefile + SECURITY.md [a-z]*.{log,out} [0-9]* appveyor.yml diff --git a/rakelib/docker-dev.rake b/rakelib/docker-dev.rake new file mode 100644 index 00000000..be39096f --- /dev/null +++ b/rakelib/docker-dev.rake @@ -0,0 +1,39 @@ +module DockerDevHelper + extend Rake::DSL + + IMAGE = "sqlite3-dev" + PROJECT_DIR = File.expand_path("..", __dir__) + DOCKERFILE = File.join(PROJECT_DIR, "misc", "Dockerfile.dev") + MOUNT_DIR = "/sqlite3" + + class << self + def build + sh "docker build -t #{IMAGE} -f #{DOCKERFILE} #{PROJECT_DIR}" + end + + def run(command) + sh "docker run --rm -v #{PROJECT_DIR}:#{MOUNT_DIR} -w #{MOUNT_DIR} #{IMAGE} #{command}" + end + end +end + +namespace "docker" do + namespace "dev" do + desc "Build a 'sqlite3-dev' docker image for development and testing" + task "build" do + DockerDevHelper.build + end + + desc "Run the test suite in a 'sqlite3-dev' container" + task "test" => "docker:dev:build" do + DockerDevHelper.run("bundle exec rake compile test") + end + + namespace "test" do + desc "Run the test suite under valgrind in a 'sqlite3-dev' container" + task "valgrind" => "docker:dev:build" do + DockerDevHelper.run("bundle exec rake compile test:valgrind") + end + end + end +end From 15c4258538d4385a01cc0e788d7d17b803eeaa69 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 6 Aug 2026 14:44:40 -0400 Subject: [PATCH 3/4] test: introduce helpers for compaction --- test/helper.rb | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/helper.rb b/test/helper.rb index e0677db6..e52afbba 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -82,8 +82,7 @@ def after_teardown end when :verify if compaction_scheduled? - # https://alanwu.space/post/check-compaction/ - GC.verify_compaction_references(expand_heap: true, toward: :empty) + gc_verify_compaction_references putc("!") end GC.start(full_mark: true) @@ -102,6 +101,15 @@ def compaction_scheduled? TestCase.test_count % COMPACT_EVERY == 0 end + def gc_verify_compaction_references + # https://alanwu.space/post/check-compaction/ + GC.verify_compaction_references(expand_heap: true, toward: :empty) + end + + def skip_unless_compaction_supported + skip("GC compaction is unsupported on this runtime") unless GC.respond_to?(:verify_compaction_references) + end + def assert_nothing_raised yield end From adbc439a1a7f4f66aef2910ec4aefdf2bbe11845 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 6 Aug 2026 19:18:47 -0400 Subject: [PATCH 4/4] ping to queue CI