Repository: Shopify/ruby_memcheck Branch: main Commit: f8d1db6ea771 Files: 42 Total size: 57.0 KB Directory structure: gitextract_9z716og8/ ├── .devcontainer/ │ ├── Dockerfile │ └── devcontainer.json ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ ├── cla.yml │ ├── lint.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .rubocop.yml ├── .ruby-version ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── exe/ │ └── ruby_memcheck ├── lib/ │ ├── ruby_memcheck/ │ │ ├── configuration.rb │ │ ├── frame.rb │ │ ├── rspec/ │ │ │ └── rake_task.rb │ │ ├── ruby_runner.rb │ │ ├── stack.rb │ │ ├── suppression.rb │ │ ├── test_helper.rb │ │ ├── test_task.rb │ │ ├── test_task_reporter.rb │ │ ├── valgrind_error.rb │ │ └── version.rb │ └── ruby_memcheck.rb ├── ruby_memcheck.gemspec ├── suppressions/ │ └── ruby.supp └── test/ ├── ruby_memcheck/ │ ├── ext/ │ │ ├── extconf_one.rb │ │ ├── extconf_two.rb │ │ ├── ruby_memcheck_c_test_one.c │ │ └── ruby_memcheck_c_test_two.c │ ├── rspec/ │ │ └── rake_task_test.rb │ ├── ruby_memcheck_suppression_test.rb │ ├── ruby_runner_test.rb │ ├── shared_test_task_reporter_tests.rb │ ├── suppressions/ │ │ └── ruby.supp │ ├── test_task_test.rb │ └── valgrind_error_test.rb ├── ruby_memcheck_test.rb └── test_helper.rb ================================================ FILE CONTENTS ================================================ ================================================ FILE: .devcontainer/Dockerfile ================================================ FROM ghcr.io/rails/devcontainer/images/ruby:3.4.7 RUN sudo apt-get update && sudo apt-get install -y valgrind ================================================ FILE: .devcontainer/devcontainer.json ================================================ { "name": "ruby_memcheck", "build": { "dockerfile": "Dockerfile" }, "features": { "ghcr.io/devcontainers/features/github-cli:1": {} } } ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "bundler" directory: "/" schedule: interval: "weekly" ================================================ FILE: .github/workflows/cla.yml ================================================ # .github/workflows/cla.yml name: Contributor License Agreement (CLA) on: pull_request_target: types: [opened, synchronize] issue_comment: types: [created] jobs: cla: runs-on: ubuntu-latest if: | (github.event.issue.pull_request && !github.event.issue.pull_request.merged_at && contains(github.event.comment.body, 'signed') ) || (github.event.pull_request && !github.event.pull_request.merged) steps: - uses: Shopify/shopify-cla-action@v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} cla-token: ${{ secrets.CLA_TOKEN }} ================================================ FILE: .github/workflows/lint.yml ================================================ name: Lint on: [push, pull_request] jobs: rubocop: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: ruby/setup-ruby@v1 - run: bundle install --jobs=3 --retry=3 --path=vendor/bundle - run: bundle exec rubocop ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: release: types: [published] jobs: release: permissions: contents: write id-token: write environment: release runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: bundler-cache: true ruby-version: 3.4.4 - uses: rubygems/release-gem@v1 ================================================ FILE: .github/workflows/test.yml ================================================ name: Test on: [push, pull_request] jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: entry: - { ruby: '3.1', allowed-failure: false } - { ruby: '3.2', allowed-failure: false } - { ruby: '3.3', allowed-failure: false } - { ruby: '3.4', allowed-failure: false } - { ruby: ruby-head, allowed-failure: false } name: ruby ${{ matrix.entry.ruby }} steps: - uses: actions/checkout@v2 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.entry.ruby }} # - run: sudo apt-get install -y valgrind # We cannot use Valgrind from apt because we need at least Valgrind 3.20.0 # to support DWARF 5 - run: | sudo apt-get update sudo apt-get install -y libc6-dbg - name: Install Valgrind from source run: | wget https://sourceware.org/pub/valgrind/valgrind-3.21.0.tar.bz2 tar xvf valgrind-3.21.0.tar.bz2 cd valgrind-3.21.0 ./configure make sudo make install - run: bundle install --jobs=3 --retry=3 - run: bundle exec rake continue-on-error: ${{ matrix.entry.allowed-failure }} ================================================ FILE: .gitignore ================================================ /.bundle/ /.yardoc /_yardoc/ /coverage/ /doc/ /pkg/ /spec/reports/ /tmp/ *.so .DS_Store ================================================ FILE: .rubocop.yml ================================================ inherit_gem: rubocop-shopify: rubocop.yml AllCops: SuggestExtensions: false Style/GlobalVars: Exclude: - test/ruby_memcheck/ext/extconf.rb Layout/CommentIndentation: AllowForAlignment: true ================================================ FILE: .ruby-version ================================================ 3.4.7 ================================================ FILE: Gemfile ================================================ # frozen_string_literal: true source "https://rubygems.org" gemspec ================================================ FILE: LICENSE.txt ================================================ The MIT License (MIT) Copyright 2021-present, Shopify Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # ruby_memcheck This gem provides a sane way to use Valgrind's memcheck on your native extension gem. ## Table of contents 1. [What is this gem?](#what-is-this-gem) 1. [Who should use this gem?](#who-should-use-this-gem) 1. [How does it work?](#how-does-it-work) 1. [Limitations](#limitations) 1. [Installation](#installation) 1. [Running a Ruby script](#running-a-ruby-script) 1. [Setup for test suites](#setup-for-test-suites) 1. [Configuration](#configuration) 1. [Suppression files](#suppression-files) 1. [License](#license) ## What is this gem? Valgrind's memcheck is a great tool to find and debug memory issues (e.g. memory leak, use-after-free, etc.). However, it doesn't work well on Ruby because Ruby does not free all of the memory it allocates during shutdown. This results in Valgrind reporting thousands (or more) false positives, making it very difficult for Valgrind to actually be useful. This gem solves the problem by using heuristics to filter out false positives. ### Who should use this gem? Only gems with native extensions can use this gem. If your gem is written in plain Ruby, this gem is not useful for you. ### How does it work? This gem runs Valgrind with the `--xml` option to generate an XML of all the errors. It will then parse the XML and use various heuristics based on the type of the error and the stack trace to filter out errors that are false positives. For more details, read [this blog post](https://blog.peterzhu.ca/ruby-memcheck/). ### Limitations Because of the aggressive heuristics used to filter out false positives, there are various limitations of what this gem can detect. 1. This gem is only expected to work on Linux. 1. This gem runs your gem's test suite to find errors and memory leaks. It will only be able to report errors on code paths that are covered by your tests. So make sure your test suite has good coverage! 1. It will not find memory leaks in Ruby. It filters out everything in Ruby. 1. It will not find memory leaks of allocations that occurred in Ruby (even if the memory leak is caused by your native extension). An example of this is if a string is allocated in Ruby, passed into your native extension, you change the pointer of the string without freeing the contents, so the contents of the string becomes leaked. 1. To filter out false positives, it will only find definite leaks (i.e. memory regions with no pointers to it). It will not find possible leaks (i.e. memory regions with pointers to it). 1. It will not find leaks that occur in the `Init` function of your native extension. 1. It will not find uses of undefined values (e.g. conditional jumps depending on undefined values). This is just a technical limitation that has not been solved yet (contributions welcome!). ## Installation ``` gem install ruby_memcheck ``` ## Running a Ruby script You can run a Ruby script under ruby_memcheck. This will report all memory leaks in all native extensions found in your Ruby script. Simply replace the `ruby` part of your command with `ruby_memcheck`. For example: ```sh $ ruby_memcheck -e "puts 'Hello world'" Hello world ``` ## Setup for test suites > **Note** > If you encounter errors from Valgrind that looks like this: > ``` > ### unhandled dwarf2 abbrev form code 0x25 > ``` > Then you need a newer version of Valgrind (>= 3.20.0) with DWARF5 support. > The current versions of Valgrind in Ubuntu Packages is not new enough. > > You can install Valgrind from source using the following commands: > ``` > sudo apt-get install -y libc6-dbg > wget https://sourceware.org/pub/valgrind/valgrind-3.21.0.tar.bz2 > tar xvf valgrind-3.21.0.tar.bz2 > cd valgrind-3.21.0 > ./configure > make > sudo make install > ``` You can use ruby_memcheck on your test suite (Minitest or RSpec) using rake. 0. Install Valgrind. 1. In your Rakefile, require this gem. ```ruby require "ruby_memcheck" ``` - **For RSpec:** If you're using RSpec, also add the following require. ```ruby require "ruby_memcheck/rspec/rake_task" ``` 1. Setup the test task for your test framework. - **minitest** Locate your test task(s) in your Rakefile. You can identify it with a call to `Rake::TestTask.new`. Create a namespace under the test task and create a `RubyMemcheck::TestTask` with the same configuration. For example, if your Rakefile looked like this before: ```ruby Rake::TestTask.new(test: :compile) do |t| t.libs << "test" t.test_files = FileList["test/unit/**/*_test.rb"] end ``` You can change it to look like this: ```ruby test_config = lambda do |t| t.libs << "test" t.test_files = FileList["test/**/*_test.rb"] end Rake::TestTask.new(test: :compile, &test_config) namespace :test do RubyMemcheck::TestTask.new(valgrind: :compile, &test_config) end ``` - **RSpec** Locate your rake task(s) in your Rakefile. You can identify it with a call to `RSpec::Core::RakeTask.new`. Create a namespace under the test task and create a `RubyMemcheck::RSpec::RakeTask` with the same configuration. For example, if your Rakefile looked like this before: ```ruby RSpec::Core::RakeTask.new(spec: :compile) ``` You can change it to look like this: ```ruby RSpec::Core::RakeTask.new(spec: :compile) namespace :spec do RubyMemcheck::RSpec::RakeTask.new(valgrind: :compile) end ``` 1. You're ready to run your test suite with Valgrind using `rake test:valgrind` or `rake spec:valgrind`! Note that this will take a while to run because Valgrind will make Ruby significantly slower. 1. (Optional) If you find false positives in the output, you can create Valgrind suppression files. See the [`Suppression files`](#suppression-files) section for more details. ## Configuration If you want to override any of the default configurations you can call `RubyMemcheck.config` after `require "ruby_memcheck"`. This will create a default `RubyMemcheck::Configuration`. By default, the Rake tasks for minitest and RSpec will use this configuration. You can also manually pass in a `Configuration` object as the first argument to the constructor of `RubyMemcheck::TestTask` or `RubyMemcheck::RSpec::RakeTask` to use a different `Configuration` object rather than the default one. `RubyMemcheck::Configuration` accepts a variety of keyword arguments. Here are all the arguments: - `binary_name`: Optional. The name of the only binary to report errors for. Use this if there is too much noise caused by other binaries. - `ruby`: Optional. The command to run to invoke Ruby. Defaults to the Ruby that is currently being used. - `valgrind`: Optional. The command to run to invoke Valgrind. Defaults to the string `"valgrind"`. - `valgrind_options`: Optional. Array of options to pass into Valgrind. This is only present as an escape hatch, so avoid using it. This may be deprecated or removed in future versions. - `valgrind_suppressions_dir`: Optional. The string path of the directory that stores suppression files for Valgrind. See the [`Suppression files`](#suppression-files) section for more details. Defaults to `suppressions`. - `valgrind_generate_suppressions`: Optional. Whether suppressions should also be outputted along with the errors. the [`Suppression files`](#suppression-files) section for more details. Defaults to `false`. - `skipped_ruby_functions`: Optional. Ruby functions that are ignored because they are considered a call back into Ruby. This is only present as an escape hatch, so avoid using it. If you find another Ruby function that is a false positive because it calls back into Ruby, please send a patch into this repo. Otherwise, use a Valgrind suppression file. - `temp_dir`: Optional. The directory to store temporary files. It defaults to a temporary directory. This is present for development debugging, so you shouldn't have to use it. - `output_io`: Optional. The `IO` object to output Valgrind errors to. Defaults to standard error. - `filter_all_errors`: Optional. Whether to filter all kinds of Valgrind errors (not just memory leaks). This feature should only be used if you're encountering a large number of illegal memory accesses coming from Ruby. If you need to use this feature, you may have found a bug inside of Ruby. Consider reporting it to the [Ruby bug tracker](https://bugs.ruby-lang.org/projects/ruby-master/issues/new). Defaults to `false`. - `use_only_ruby_free_at_exit`: Optional. Use only the [`RUBY_FREE_AT_EXIT`](https://bugs.ruby-lang.org/issues/19993) feature introduced in Ruby 3.3 and disables most of the heuristics inside of ruby_memcheck. Disable this if you want to use the original heuristics. Defaults to `true` for Ruby 3.4 and later, `false` otherwise. Note: while `RUBY_FREE_AT_EXIT` was introduced in Ruby 3.3, there are bugs which prevents it from working well, so it is only enabled by default for Ruby 3.4 and later. ## Suppression files If you find false positives in the output, you can create suppression files in a `suppressions` directory in the root directory of your gem. In this directory, you can create [Valgrind suppression files](https://wiki.wxwidgets.org/Valgrind_Suppression_File_Howto). The most basic suppression file is `ruby.supp`. If you want some suppressions for only specific versions of Ruby, you can add the Ruby version to the filename. For example, `ruby-3.supp` will suppress for any Rubies with a major version of 3 (e.g. 3.0.0, 3.1.1, etc.), while suppression file `ruby-3.1.supp` will only be used for Ruby with a major and minor version of 3.1 (e.g. 3.1.0, 3.1.1, etc.). ## Success stories Let's celebrate wins from this gem! If this gem was useful for you, please share your story below too! - [`liquid-c`](https://github.com/Shopify/liquid-c): - Found 2 memory leaks: [#157](https://github.com/Shopify/liquid-c/pull/157), [#161](https://github.com/Shopify/liquid-c/pull/161) - Running on CI: [#162](https://github.com/Shopify/liquid-c/pull/162) - [`nokogiri`](https://github.com/sparklemotion/nokogiri): - Found 5 memory leaks: [4 in #2345](https://github.com/sparklemotion/nokogiri/pull/2345), [#2347](https://github.com/sparklemotion/nokogiri/pull/2347) - Running on CI: [#2344](https://github.com/sparklemotion/nokogiri/pull/2344) - [`rotoscope`](https://github.com/Shopify/rotoscope): - Found a [memory leak in Ruby TracePoint](https://bugs.ruby-lang.org/issues/18264) - Running on CI: [#89](https://github.com/Shopify/rotoscope/pull/89) - [`protobuf`](https://github.com/protocolbuffers/protobuf): - Found 1 memory leak: [#9150](https://github.com/protocolbuffers/protobuf/pull/9150) - [`gRPC`](https://github.com/grpc/grpc): - Found 1 memory leak: [#27900](https://github.com/grpc/grpc/pull/27900) - [`wasmtime-rb`](https://github.com/bytecodealliance/wasmtime-rb): - Found 1 memory leak: [#26](https://github.com/bytecodealliance/wasmtime-rb/pull/26) - [`yarp`](https://github.com/shopify/yarp): - Found 6 memory leaks and 1 memory error: [#292](https://github.com/Shopify/yarp/pull/304), [#292](https://github.com/Shopify/yarp/pull/292) - Running on CI: [#293](https://github.com/Shopify/yarp/pull/293) - [`libxml2`](https://gitlab.gnome.org/GNOME/libxml2): - Found 1 memory leak: [memory leak from \`xmlSchemaValidateStream\` in v2.11.x (#530)](https://gitlab.gnome.org/GNOME/libxml2/-/issues/530) - Running in Nokogiri's CI pipeline: [#2868](https://github.com/sparklemotion/nokogiri/pull/2868) - [`re2`](https://github.com/mudge/re2): - Found 8 memory leaks: [#105](https://github.com/mudge/re2/pull/105) - Running on CI: [#149](https://github.com/mudge/re2/pull/149) ## License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). ================================================ FILE: Rakefile ================================================ # frozen_string_literal: true require "bundler/gem_tasks" require "rake/testtask" require "rake/extensiontask" Rake::TestTask.new(test: "test:compile") do |t| t.libs << "test" t.libs << "lib" t.test_files = FileList["test/**/*_test.rb"] end namespace :test do Rake::ExtensionTask.new("ruby_memcheck_c_test_one") do |ext| ext.ext_dir = "test/ruby_memcheck/ext" ext.lib_dir = "test/ruby_memcheck/ext" ext.config_script = "extconf_one.rb" end Rake::ExtensionTask.new("ruby_memcheck_c_test_two") do |ext| ext.ext_dir = "test/ruby_memcheck/ext" ext.lib_dir = "test/ruby_memcheck/ext" ext.config_script = "extconf_two.rb" end end task default: :test ================================================ FILE: exe/ruby_memcheck ================================================ #!/usr/bin/env ruby # frozen_string_literal: true $LOAD_PATH.unshift("#{__dir__}/../lib") require "ruby_memcheck" runner = RubyMemcheck::RubyRunner.new exit(runner.run(*ARGV)) ================================================ FILE: lib/ruby_memcheck/configuration.rb ================================================ # frozen_string_literal: true module RubyMemcheck class Configuration DEFAULT_VALGRIND = "valgrind" DEFAULT_VALGRIND_OPTIONS = [ "--num-callers=50", "--error-limit=no", "--trace-children=yes", "--undef-value-errors=no", "--leak-check=full", "--show-leak-kinds=definite", ].freeze DEFAULT_VALGRIND_SUPPRESSIONS_DIR = "suppressions" DEFAULT_SKIPPED_RUBY_FUNCTIONS = [ /\Aeval_string_with_cref\z/, /\Aintern_str\z/, # Same as rb_intern, but sometimes rb_intern is optimized out /\Arb_add_method_cfunc\z/, /\Arb_check_funcall/, /\Arb_class_boot\z/, # Called for all the different ways to create a Class /\Arb_enc_raise\z/, /\Arb_exc_raise\z/, /\Arb_extend_object\z/, /\Arb_funcall/, /\Arb_intern/, /\Arb_ivar_set\z/, /\Arb_module_new\z/, /\Arb_raise\z/, /\Arb_rescue/, /\Arb_respond_to\z/, /\Arb_thread_create\z/, # Threads are relased to a cache, so they may be reported as a leak /\Arb_vm_exec\z/, /\Arb_yield/, ].freeze RUBY_FREE_AT_EXIT_SUPPORTED = Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.4.0") attr_reader :binary_name attr_reader :ruby attr_reader :valgrind attr_reader :valgrind_options attr_reader :valgrind_suppression_files attr_reader :valgrind_generate_suppressions attr_reader :skipped_ruby_functions attr_reader :temp_dir attr_reader :loaded_features_file attr_reader :output_io attr_reader :filter_all_errors attr_reader :use_only_ruby_free_at_exit alias_method :valgrind_generate_suppressions?, :valgrind_generate_suppressions alias_method :filter_all_errors?, :filter_all_errors alias_method :use_only_ruby_free_at_exit?, :use_only_ruby_free_at_exit def initialize( binary_name: nil, ruby: FileUtils::RUBY, valgrind: DEFAULT_VALGRIND, valgrind_options: DEFAULT_VALGRIND_OPTIONS, valgrind_suppressions_dir: DEFAULT_VALGRIND_SUPPRESSIONS_DIR, valgrind_generate_suppressions: false, skipped_ruby_functions: DEFAULT_SKIPPED_RUBY_FUNCTIONS, temp_dir: Dir.mktmpdir, output_io: $stderr, filter_all_errors: false, use_only_ruby_free_at_exit: RUBY_FREE_AT_EXIT_SUPPORTED ) @binary_name = binary_name @ruby = ruby @valgrind = valgrind @valgrind_options = valgrind_options @valgrind_suppression_files = get_valgrind_suppression_files(File.join(__dir__, "../../suppressions")) + get_valgrind_suppression_files(valgrind_suppressions_dir) @valgrind_generate_suppressions = valgrind_generate_suppressions @skipped_ruby_functions = skipped_ruby_functions @output_io = output_io @filter_all_errors = filter_all_errors temp_dir = File.expand_path(temp_dir) FileUtils.mkdir_p(temp_dir) @temp_dir = temp_dir @valgrind_options += [ "--xml=yes", # %p will be replaced with the PID # This prevents forking and shelling out from generating a corrupted XML # See --log-file from https://valgrind.org/docs/manual/manual-core.html "--xml-file=#{File.join(temp_dir, "%p.xml")}", ] @loaded_features_file = Tempfile.create("", @temp_dir) @use_only_ruby_free_at_exit = use_only_ruby_free_at_exit end def command(*args) [ # On some Rubies, not setting the stack size to be ulimited causes # Valgrind to report the following error: # Invalid write of size 1 # reserve_stack (thread_pthread.c:845) # ruby_init_stack (thread_pthread.c:871) # main (main.c:48) "ulimit -s unlimited && ", # On some distros, and in some Docker containers, the number of file descriptors is set to a # very high number like 1073741816 that valgrind >= 3.21.0 will error out on: # --184100:0:libcfile Valgrind: FATAL: Private file creation failed. # The current file descriptor limit is 1073741804. # If you are running in Docker please consider # lowering this limit with the shell built-in limit command. # --184100:0:libcfile Exiting now. # See https://bugs.kde.org/show_bug.cgi?id=465435 for background information. "ulimit -n 8192 && ", valgrind, valgrind_options, valgrind_suppression_files.map { |f| "--suppressions=#{f}" }, valgrind_generate_suppressions ? "--gen-suppressions=all" : "", ruby, "-r" + File.expand_path(File.join(__dir__, "test_helper.rb")), args, ].flatten.join(" ") end private def get_valgrind_suppression_files(dir) dir = File.expand_path(dir) full_ruby_version = "#{RUBY_ENGINE}-#{RUBY_VERSION}.#{RUBY_PATCHLEVEL}" versions = [full_ruby_version] (0..3).reverse_each { |i| versions << full_ruby_version.split(".")[0, i].join(".") } versions << RUBY_ENGINE versions.map do |version| Dir[File.join(dir, "#{version}.supp")] end.flatten end end end ================================================ FILE: lib/ruby_memcheck/frame.rb ================================================ # frozen_string_literal: true module RubyMemcheck class Frame attr_reader :configuration, :loaded_binaries, :fn, :obj, :file, :line def initialize(configuration, loaded_binaries, frame_xml) @configuration = configuration @loaded_binaries = loaded_binaries @fn = frame_xml.at_xpath("fn")&.content @obj = frame_xml.at_xpath("obj")&.content # file and line may not be available @file = frame_xml.at_xpath("file")&.content @line = frame_xml.at_xpath("line")&.content end def in_ruby? return false unless obj obj == configuration.ruby || # Hack to fix Ruby built with --enabled-shared File.basename(obj) == "libruby.so.#{RUBY_VERSION}" end def in_binary? return false unless obj loaded_binaries.include?(obj) end def binary_init_func? return false unless in_binary? binary_name = File.basename(obj, ".*") fn == "Init_#{binary_name}" end def to_s if file "#{fn} (#{file}:#{line})" elsif fn "#{fn} (at #{obj})" else "" end end end end ================================================ FILE: lib/ruby_memcheck/rspec/rake_task.rb ================================================ # frozen_string_literal: true require "rspec/core/rake_task" module RubyMemcheck module RSpec class RakeTask < ::RSpec::Core::RakeTask attr_reader :configuration attr_reader :reporter def initialize(*args) @configuration = if !args.empty? && args[0].is_a?(Configuration) args.shift else RubyMemcheck.default_configuration end super end def run_task(verbose) error = nil @reporter = TestTaskReporter.new(configuration) @reporter.run_ruby_with_valgrind do # RSpec::Core::RakeTask#run_task calls Kernel.exit on failure super rescue SystemExit => e error = e end raise error if error end private def spec_command # First part of command is Ruby args = super.split(" ")[1..] configuration.command(args) end end end end ================================================ FILE: lib/ruby_memcheck/ruby_runner.rb ================================================ # frozen_string_literal: true module RubyMemcheck class RubyRunner attr_reader :configuration attr_reader :reporter def initialize(*args) @configuration = if !args.empty? && args[0].is_a?(Configuration) args.shift else RubyMemcheck.default_configuration end end def run(*args, **options) command = configuration.command(args.map { |a| Shellwords.escape(a) }) @reporter = TestTaskReporter.new(configuration) @reporter.setup system(command, options) exit_code = $CHILD_STATUS.exitstatus @reporter.report_valgrind_errors exit_code end end end ================================================ FILE: lib/ruby_memcheck/stack.rb ================================================ # frozen_string_literal: true module RubyMemcheck class Stack attr_reader :configuration, :frames def initialize(configuration, loaded_binaries, stack_xml) @configuration = configuration @frames = stack_xml.xpath("frame").map { |frame| Frame.new(configuration, loaded_binaries, frame) } end def skip? if @configuration.use_only_ruby_free_at_exit? skip_using_ruby_free_at_exit? else skip_using_original_heuristics? end end private def skip_using_ruby_free_at_exit? if configuration.binary_name.nil? false else in_binary = false frames.each do |frame| if frame.in_binary? in_binary = true end end !in_binary end end def skip_using_original_heuristics? in_binary = false frames.each do |frame| if frame.in_ruby? # If a stack from from the binary was encountered first, then this # memory leak did not occur from Ruby unless in_binary # Skip this stack because it was called from Ruby return true if configuration.skipped_ruby_functions.any? { |r| r.match?(frame.fn) } end elsif frame.in_binary? in_binary = true # Skip the Init function because it is only ever called once, so # leaks in it cannot cause memory bloat return true if frame.binary_init_func? end end # Skip if the stack was never in the binary because it is very likely # not a leak in the native gem !in_binary end end end ================================================ FILE: lib/ruby_memcheck/suppression.rb ================================================ # frozen_string_literal: true module RubyMemcheck class Suppression attr_reader :root def initialize(configuration, suppression_node) @root = suppression_node end def to_s str = StringIO.new str << "{\n" str << " #{root.at_xpath("sname").content}\n" str << " #{root.at_xpath("skind").content}\n" root.xpath("./sframe/fun | ./sframe/obj").each do |frame| str << " #{frame.name}:#{frame.content}\n" end str << "}\n" str.string end end end ================================================ FILE: lib/ruby_memcheck/test_helper.rb ================================================ # frozen_string_literal: true at_exit do File.open(ENV["RUBY_MEMCHECK_LOADED_FEATURES_FILE"], "w") do |f| f.write($LOADED_FEATURES.join("\n")) end # We need to remove the @_memoized instance variable from Minitest::Spec # objects because it holds a hash that contains memoized objects in `let` # blocks, this can contain objects that will be reported as a memory leak. if defined?(Minitest::Spec) require "objspace" ObjectSpace.each_object(Minitest::Spec) do |obj| if obj.instance_variable_defined?(:@_memoized) obj.remove_instance_variable(:@_memoized) end end end GC.start end ================================================ FILE: lib/ruby_memcheck/test_task.rb ================================================ # frozen_string_literal: true module RubyMemcheck class TestTask < Rake::TestTask attr_reader :configuration attr_reader :reporter def initialize(*args) @configuration = if !args.empty? && args[0].is_a?(Configuration) args.shift else RubyMemcheck.default_configuration end super end def ruby(*args, **options, &block) command = configuration.command(args) @reporter = TestTaskReporter.new(configuration) @reporter.setup sh(command, **options) do |ok, res| @reporter.report_valgrind_errors yield ok, res if block_given? end end end end ================================================ FILE: lib/ruby_memcheck/test_task_reporter.rb ================================================ # frozen_string_literal: true module RubyMemcheck class TestTaskReporter VALGRIND_REPORT_MSG = "Valgrind reported errors (e.g. memory leak or use-after-free)" attr_reader :configuration attr_reader :errors def initialize(configuration) @configuration = configuration @loaded_binaries = nil end def run_ruby_with_valgrind(&block) setup yield report_valgrind_errors end def setup ENV["RUBY_MEMCHECK_LOADED_FEATURES_FILE"] = File.expand_path(configuration.loaded_features_file) ENV["RUBY_MEMCHECK_RUNNING"] = "1" ENV["RUBY_FREE_AT_EXIT"] = "1" end def report_valgrind_errors parse_valgrind_output remove_valgrind_xml_files unless errors.empty? output_valgrind_errors raise VALGRIND_REPORT_MSG end end private def loaded_binaries return @loaded_binaries if @loaded_binaries loaded_features = File.readlines(configuration.loaded_features_file, chomp: true) @loaded_binaries = loaded_features.keep_if do |feat| # Keep only binaries (ignore Ruby files). File.extname(feat) == ".so" end if configuration.binary_name @loaded_binaries.keep_if do |feat| File.basename(feat, ".*") == configuration.binary_name end if @loaded_binaries.empty? raise "The Ruby program executed never loaded a binary called `#{configuration.binary_name}`" end end @loaded_binaries.freeze end def valgrind_xml_files @valgrind_xml_files ||= Dir[File.join(configuration.temp_dir, "*.xml")].freeze end def parse_valgrind_output require "nokogiri" @errors = [] valgrind_xml_files.each do |file| reader = Nokogiri::XML::Reader(File.open(file)) do |config| # rubocop:disable Style/SymbolProc config.huge end reader.each do |node| next unless node.name == "error" && node.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT error_xml = Nokogiri::XML::Document.parse(node.outer_xml).root error = ValgrindError.new(configuration, loaded_binaries, error_xml) next if error.skip? @errors << error end end end def remove_valgrind_xml_files valgrind_xml_files.each do |file| File.delete(file) end end def output_valgrind_errors @errors.each do |error| configuration.output_io.puts error configuration.output_io.puts end end end end ================================================ FILE: lib/ruby_memcheck/valgrind_error.rb ================================================ # frozen_string_literal: true module RubyMemcheck class ValgrindError SUPPRESSION_NOT_CONFIGURED_ERROR_MSG = "Please enable suppressions by configuring with valgrind_generate_suppressions set to true" attr_reader :kind, :msg, :stack, :suppression def initialize(configuration, loaded_binaries, error) @kind = error.at_xpath("kind").content @msg = if kind_leak? error.at_xpath("xwhat/text").content else error.at_xpath("what").content end @stack = Stack.new(configuration, loaded_binaries, error.at_xpath("stack")) @configuration = configuration suppression_node = error.at_xpath("suppression") if configuration.valgrind_generate_suppressions? @suppression = Suppression.new(configuration, suppression_node) elsif suppression_node raise SUPPRESSION_NOT_CONFIGURED_ERROR_MSG end end def skip? should_filter? && stack.skip? end def to_s str = StringIO.new str << "#{msg}\n" stack.frames.each do |frame| str << if frame.in_binary? " *#{frame}\n" else " #{frame}\n" end end str << suppression.to_s if suppression str.string end private def should_filter? @configuration.filter_all_errors? || kind_leak? end def kind_leak? kind.start_with?("Leak_") end end end ================================================ FILE: lib/ruby_memcheck/version.rb ================================================ # frozen_string_literal: true module RubyMemcheck VERSION = "3.0.1" end ================================================ FILE: lib/ruby_memcheck.rb ================================================ # frozen_string_literal: true require "English" require "shellwords" require "tempfile" require "rake/testtask" require "ruby_memcheck/configuration" require "ruby_memcheck/frame" require "ruby_memcheck/ruby_runner" require "ruby_memcheck/stack" require "ruby_memcheck/test_task_reporter" require "ruby_memcheck/test_task" require "ruby_memcheck/valgrind_error" require "ruby_memcheck/suppression" require "ruby_memcheck/version" module RubyMemcheck class << self def config(**opts) @default_configuration = Configuration.new(**opts) end def default_configuration @default_configuration ||= config end end end ================================================ FILE: ruby_memcheck.gemspec ================================================ # frozen_string_literal: true require_relative "lib/ruby_memcheck/version" Gem::Specification.new do |spec| spec.name = "ruby_memcheck" spec.version = RubyMemcheck::VERSION spec.authors = ["Peter Zhu"] spec.email = ["peter@peterzhu.ca"] spec.summary = "Use Valgrind memcheck without going crazy" spec.homepage = "https://github.com/Shopify/ruby_memcheck" spec.license = "MIT" spec.required_ruby_version = ">= 3.0.0" spec.metadata["homepage_uri"] = spec.homepage # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do %x(git ls-files -z).split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } end spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] spec.add_dependency("nokogiri") spec.add_development_dependency("minitest", "~> 5.0") spec.add_development_dependency("minitest-parallel_fork", "~> 2.0") spec.add_development_dependency("rake", "~> 13.0") spec.add_development_dependency("rake-compiler", "~> 1.1") spec.add_development_dependency("rspec-core") spec.add_development_dependency("rubocop", "~> 1.22") spec.add_development_dependency("rubocop-shopify", "~> 2.3") end ================================================ FILE: suppressions/ruby.supp ================================================ { On platforms where memcpy is safe for overlapped memory, the compiler will sometimes replace memmove with memcpy. Valgrind may report a false positive. Memcheck:Overlap fun:__memcpy_chk fun:memmove ... } { Requiring a file will add it to the loaded features, which may be reported as a leak. Memcheck:Leak ... fun:require_internal ... } { recursive_list_access creates a hash called `list` that is stored on the threadptr_recursive_hash. This is reported as a memory leak. Memcheck:Leak ... fun:rb_ident_hash_new fun:recursive_list_access fun:exec_recursive ... } { "Invalid read of size 8" when marking the stack of fibers Memcheck:Addr8 fun:each_location* ... } { Rust probes for statx(buf), will be fixed in Valgrind >= 3.1.6.0 Memcheck:Param statx(buf) ... fun:*try_statx* ... } { Rust probes for statx(file_name), will be fixed in Valgrind >= 3.1.6.0 Memcheck:Param statx(file_name) ... fun:*try_statx* ... } { strscan_do_scan in strscan.c will sometimes replace the ptr of the regex, which can be reported as a memory leak if the regex is stored in an iseq. https://github.com/ruby/ruby/pull/8136 Memcheck:Leak ... fun:rb_reg_prepare_re fun:strscan_do_scan ... } { The callcache table (RCLASS_CC_TBL) is lazily created, so it is allocated when the first method that gets cached. If this happens in a native extension, it may be reported as a memory leak. Memcheck:Leak ... fun:rb_id_table_create ... fun:rb_callable_method_entry ... } { The date library lazily initializes Regexps using static local variables through the function `regcomp`. The Regexp will end up being reported as a memory leak. Memcheck:Leak ... fun:rb_enc_reg_new ... fun:date__parse ... } ================================================ FILE: test/ruby_memcheck/ext/extconf_one.rb ================================================ # frozen_string_literal: true require "mkmf" $warnflags&.gsub!("-Wdeclaration-after-statement", "") # rubocop:disable Style/GlobalVars create_makefile("ruby_memcheck_c_test_one") ================================================ FILE: test/ruby_memcheck/ext/extconf_two.rb ================================================ # frozen_string_literal: true require "mkmf" $warnflags&.gsub!("-Wdeclaration-after-statement", "") # rubocop:disable Style/GlobalVars create_makefile("ruby_memcheck_c_test_two") ================================================ FILE: test/ruby_memcheck/ext/ruby_memcheck_c_test_one.c ================================================ #include static VALUE cRubyMemcheckCTestOne; static VALUE c_test_one_no_memory_leak(VALUE _) { return Qnil; } /* This function must not be inlined to ensure that it has a stack frame. */ static void __attribute__((noinline)) c_test_one_allocate_memory_leak(void) { volatile char *ptr = malloc(100); ptr[0] = 'a'; } static VALUE c_test_one_memory_leak(VALUE _) { c_test_one_allocate_memory_leak(); return Qnil; } static VALUE c_test_one_use_after_free(VALUE _) { volatile char *ptr = malloc(100); free((void *)ptr); ptr[0] = 'a'; return Qnil; } static VALUE c_test_one_uninitialized_value(VALUE _) { volatile int foo; #pragma GCC diagnostic ignored "-Wuninitialized" return foo == 0 ? rb_str_new_cstr("zero") : rb_str_new_cstr("not zero"); #pragma GCC diagnostic pop } static VALUE c_test_one_call_into_ruby_mem_leak(VALUE obj) { VALUE string = rb_eval_string("String.new(capacity: 10_000)"); RSTRING(string)->as.heap.ptr = NULL; return Qnil; } void Init_ruby_memcheck_c_test_one(void) { /* Memory leaks in the Init functions should be ignored. */ c_test_one_allocate_memory_leak(); VALUE mRubyMemcheck = rb_define_module("RubyMemcheck"); cRubyMemcheckCTestOne = rb_define_class_under(mRubyMemcheck, "CTestOne", rb_cObject); rb_global_variable(&cRubyMemcheckCTestOne); rb_define_method(cRubyMemcheckCTestOne, "no_memory_leak", c_test_one_no_memory_leak, 0); rb_define_method(cRubyMemcheckCTestOne, "memory_leak", c_test_one_memory_leak, 0); rb_define_method(cRubyMemcheckCTestOne, "use_after_free", c_test_one_use_after_free, 0); rb_define_method(cRubyMemcheckCTestOne, "uninitialized_value", c_test_one_uninitialized_value, 0); rb_define_method(cRubyMemcheckCTestOne, "call_into_ruby_mem_leak", c_test_one_call_into_ruby_mem_leak, 0); } ================================================ FILE: test/ruby_memcheck/ext/ruby_memcheck_c_test_two.c ================================================ #include static VALUE cRubyMemcheckCTestTwo; static VALUE c_test_two_no_memory_leak(VALUE _) { return Qnil; } /* This function must not be inlined to ensure that it has a stack frame. */ static void __attribute__((noinline)) c_test_two_allocate_memory_leak(void) { volatile char *ptr = malloc(100); ptr[0] = 'a'; } static VALUE c_test_two_memory_leak(VALUE _) { c_test_two_allocate_memory_leak(); return Qnil; } static VALUE c_test_two_use_after_free(VALUE _) { volatile char *ptr = malloc(100); free((void *)ptr); ptr[0] = 'a'; return Qnil; } static VALUE c_test_two_uninitialized_value(VALUE _) { volatile int foo; #pragma GCC diagnostic ignored "-Wuninitialized" return foo == 0 ? rb_str_new_cstr("zero") : rb_str_new_cstr("not zero"); #pragma GCC diagnostic pop } static VALUE c_test_two_call_into_ruby_mem_leak(VALUE obj) { VALUE string = rb_eval_string("String.new(capacity: 10_000)"); RSTRING(string)->as.heap.ptr = NULL; return Qnil; } void Init_ruby_memcheck_c_test_two(void) { /* Memory leaks in the Init functions should be ignored. */ c_test_two_allocate_memory_leak(); VALUE mRubyMemcheck = rb_define_module("RubyMemcheck"); cRubyMemcheckCTestTwo = rb_define_class_under(mRubyMemcheck, "CTestTwo", rb_cObject); rb_global_variable(&cRubyMemcheckCTestTwo); rb_define_method(cRubyMemcheckCTestTwo, "no_memory_leak", c_test_two_no_memory_leak, 0); rb_define_method(cRubyMemcheckCTestTwo, "memory_leak", c_test_two_memory_leak, 0); rb_define_method(cRubyMemcheckCTestTwo, "use_after_free", c_test_two_use_after_free, 0); rb_define_method(cRubyMemcheckCTestTwo, "uninitialized_value", c_test_two_uninitialized_value, 0); rb_define_method(cRubyMemcheckCTestTwo, "call_into_ruby_mem_leak", c_test_two_call_into_ruby_mem_leak, 0); } ================================================ FILE: test/ruby_memcheck/rspec/rake_task_test.rb ================================================ # frozen_string_literal: true require "ruby_memcheck" require "ruby_memcheck/rspec/rake_task" require "ruby_memcheck/shared_test_task_reporter_tests" module RubyMemcheck module RSpec class RakeTaskTest < Minitest::Test include SharedTestTaskReporterTests def setup @output_io = StringIO.new build_configuration end private def run_with_memcheck(code, raise_on_failure: true, spawn_opts: {}) ok = true stdout = nil Dir.chdir(Dir.mktmpdir) do |dir| spec_dir = File.join(dir, "spec") Dir.mkdir(spec_dir) stdout_log = Tempfile.new("", spec_dir) script = Tempfile.new(["", "_spec.rb"], spec_dir) script.write(<<~RUBY) # Redirect stdout to log file for RSpec output $stdout.reopen(File.open("#{stdout_log.path}", "w")) $LOAD_PATH.unshift("#{File.join(__dir__, "../ext")}") require "ruby_memcheck_c_test_one" require "ruby_memcheck_c_test_two" RSpec.describe RubyMemcheck do it "test" do #{code} end end RUBY script.flush begin @test_task.run_task(false) rescue SystemExit # RSpec::Core::RakeTask#run_task calls Kernel.exit on failure ok = false end # Get the stdout of RSpec stdout = File.read(stdout_log.path) # Check RSpec test passed unless /^1 example, 0 failures$/.match?(stdout) ok = false end end if raise_on_failure && !ok raise "Command failed. stdout:\n#{stdout}" end ok end def build_test_task @test_task = RubyMemcheck::RSpec::RakeTask.new(@configuration) end end end end ================================================ FILE: test/ruby_memcheck/ruby_memcheck_suppression_test.rb ================================================ # frozen_string_literal: true require "test_helper" require "nokogiri" module RubyMemcheck class RubyMemcheckSuppressionTest < Minitest::Test def setup @configuration = Configuration.new end def test_given_a_suppression_node suppression = ::Nokogiri::XML(<<~EOF).at_xpath("//suppression") insert_a_suppression_name_here Memcheck:Leak match-leak-kinds: definite malloc objspace_xmalloc0 ruby_xmalloc0 /usr/lib/libX11.so.6.3.0 ruby_xmalloc_body ruby_xmalloc EOF expected = <<~EOF { insert_a_suppression_name_here Memcheck:Leak fun:malloc fun:objspace_xmalloc0 fun:ruby_xmalloc0 obj:/usr/lib/libX11.so.6.3.0 fun:ruby_xmalloc_body fun:ruby_xmalloc } EOF assert_equal( expected, RubyMemcheck::Suppression.new(@configuration, suppression).to_s, ) end end end ================================================ FILE: test/ruby_memcheck/ruby_runner_test.rb ================================================ # frozen_string_literal: true require "ruby_memcheck/shared_test_task_reporter_tests" module RubyMemcheck class RubyRunnerTest < Minitest::Test include SharedTestTaskReporterTests def setup @output_io = StringIO.new build_configuration end private def run_with_memcheck(code, raise_on_failure: true, spawn_opts: {}) script = Tempfile.new script.write(<<~RUBY) require "ruby_memcheck_c_test_one" require "ruby_memcheck_c_test_two" #{code} RUBY script.flush exit_code = @test_task.run( "-I#{File.join(__dir__, "ext")}", script.path, **spawn_opts, ) if raise_on_failure && exit_code != 0 raise "Command failed with status (#{exit_code})" end exit_code == 0 end def build_test_task @test_task = RubyMemcheck::RubyRunner.new(@configuration) end end end ================================================ FILE: test/ruby_memcheck/shared_test_task_reporter_tests.rb ================================================ # frozen_string_literal: true require "test_helper" module RubyMemcheck module SharedTestTaskReporterTests def test_succeeds_when_there_is_no_memory_leak ok = run_with_memcheck(<<~RUBY) RubyMemcheck::CTestOne.new.no_memory_leak RUBY assert(ok) assert_empty(@test_task.reporter.errors) assert_empty(@output_io.string) end def test_reports_memory_leak error = assert_raises do run_with_memcheck(<<~RUBY) RubyMemcheck::CTestOne.new.memory_leak RUBY end assert_equal(RubyMemcheck::TestTaskReporter::VALGRIND_REPORT_MSG, error.message) output = @output_io.string assert_equal(1, @test_task.reporter.errors.length, output) refute_empty(output) assert_match(/^100 bytes in 1 blocks are definitely lost in loss record/, output) assert_match(/^ \*c_test_one_memory_leak \(ruby_memcheck_c_test_one\.c:\d+\)$/, output) end def test_reports_use_after_free error = assert_raises do run_with_memcheck(<<~RUBY) RubyMemcheck::CTestOne.new.use_after_free RUBY end assert_equal(RubyMemcheck::TestTaskReporter::VALGRIND_REPORT_MSG, error.message) output = @output_io.string assert_equal(1, @test_task.reporter.errors.length, output) refute_empty(output) assert_match(/^Invalid write of size 1$/, output) assert_match(/^ \*c_test_one_use_after_free \(ruby_memcheck_c_test_one\.c:\d+\)$/, output) end # Potential improvement: support uninitialized values def test_does_not_report_uninitialized_value run_with_memcheck(<<~RUBY) RubyMemcheck::CTestOne.new.uninitialized_value RUBY assert_equal(0, @test_task.reporter.errors.length, @output_io.string) assert_empty(@test_task.reporter.errors) assert_empty(@output_io.string) end def test_call_into_ruby_mem_leak_does_not_report_when_RUBY_FREE_AT_EXIT_is_not_supported skip if Configuration::RUBY_FREE_AT_EXIT_SUPPORTED ok = run_with_memcheck(<<~RUBY) RubyMemcheck::CTestOne.new.call_into_ruby_mem_leak RUBY assert(ok) assert_empty(@test_task.reporter.errors) assert_empty(@output_io.string) end def test_call_into_ruby_mem_leak_not_report_when_RUBY_FREE_AT_EXIT_is_supported skip unless Configuration::RUBY_FREE_AT_EXIT_SUPPORTED error = assert_raises do run_with_memcheck(<<~RUBY) RubyMemcheck::CTestOne.new.call_into_ruby_mem_leak RUBY end assert_equal(RubyMemcheck::TestTaskReporter::VALGRIND_REPORT_MSG, error.message) output = @output_io.string assert_equal(1, @test_task.reporter.errors.length, output) refute_empty(output) assert_match(/^ \*c_test_one_call_into_ruby_mem_leak \(ruby_memcheck_c_test_one\.c:\d+\)$/, output) end def test_call_into_ruby_mem_leak_not_report_when_RUBY_FREE_AT_EXIT_is_supported_but_use_only_ruby_free_at_exit_disabled skip unless Configuration::RUBY_FREE_AT_EXIT_SUPPORTED build_configuration(use_only_ruby_free_at_exit: false) ok = run_with_memcheck(<<~RUBY) RubyMemcheck::CTestOne.new.call_into_ruby_mem_leak RUBY assert(ok) assert_empty(@test_task.reporter.errors) assert_empty(@output_io.string) end def test_call_into_ruby_mem_leak_reports_when_not_skipped build_configuration(skipped_ruby_functions: []) error = assert_raises do run_with_memcheck(<<~RUBY) RubyMemcheck::CTestOne.new.call_into_ruby_mem_leak RUBY end assert_equal(RubyMemcheck::TestTaskReporter::VALGRIND_REPORT_MSG, error.message) assert_operator(@test_task.reporter.errors.length, :>=, 1, @output_io.string) end def test_suppressions build_configuration(valgrind_suppressions_dir: File.join(__dir__, "suppressions")) ok = run_with_memcheck(<<~RUBY) RubyMemcheck::CTestOne.new.memory_leak RUBY assert(ok) assert_empty(@test_task.reporter.errors) assert_empty(@output_io.string) end def test_generation_of_suppressions build_configuration(valgrind_generate_suppressions: true) error = assert_raises do run_with_memcheck(<<~RUBY) RubyMemcheck::CTestOne.new.memory_leak RUBY end assert_equal(RubyMemcheck::TestTaskReporter::VALGRIND_REPORT_MSG, error.message) output = @output_io.string assert_equal(1, @test_task.reporter.errors.length, output) refute_empty(output) assert_match(/^100 bytes in 1 blocks are definitely lost in loss record/, output) assert_match(/^ \*c_test_one_memory_leak \(ruby_memcheck_c_test_one\.c:\d+\)$/, output) assert_match(/^ insert_a_suppression_name_here/, output) assert_match(/^ Memcheck:Leak/, output) assert_match(/^ fun:c_test_one_allocate_memory_leak/, output) end def test_follows_forked_children error = assert_raises do run_with_memcheck(<<~RUBY) pid = Process.fork do RubyMemcheck::CTestOne.new.memory_leak end Process.wait(pid) RUBY end assert_equal(RubyMemcheck::TestTaskReporter::VALGRIND_REPORT_MSG, error.message) output = @output_io.string assert_equal(1, @test_task.reporter.errors.length, output) refute_empty(output) assert_match(/^100 bytes in 1 blocks are definitely lost in loss record/, output) assert_match(/^ \*c_test_one_memory_leak \(ruby_memcheck_c_test_one\.c:\d+\)$/, output) end def test_reports_multiple_errors error = assert_raises do run_with_memcheck(<<~RUBY) RubyMemcheck::CTestOne.new.memory_leak RubyMemcheck::CTestOne.new.use_after_free RUBY end assert_equal(RubyMemcheck::TestTaskReporter::VALGRIND_REPORT_MSG, error.message) output = @output_io.string assert_equal(2, @test_task.reporter.errors.length, output) refute_empty(output) assert_match(/^100 bytes in 1 blocks are definitely lost in loss record/, output) assert_match(/^ \*c_test_one_memory_leak \(ruby_memcheck_c_test_one\.c:\d+\)$/, output) assert_match(/^Invalid write of size 1$/, output) assert_match(/^ \*c_test_one_use_after_free \(ruby_memcheck_c_test_one\.c:\d+\)$/, output) end def test_reports_errors_in_all_binaries error = assert_raises do run_with_memcheck(<<~RUBY) RubyMemcheck::CTestOne.new.memory_leak RubyMemcheck::CTestTwo.new.memory_leak RUBY end assert_equal(RubyMemcheck::TestTaskReporter::VALGRIND_REPORT_MSG, error.message) output = @output_io.string assert_equal(2, @test_task.reporter.errors.length, output) refute_empty(output) assert_match(/^100 bytes in 1 blocks are definitely lost in loss record/, output) assert_match(/^ \*c_test_one_memory_leak \(ruby_memcheck_c_test_one\.c:\d+\)$/, output) assert_match(/^ \*c_test_two_memory_leak \(ruby_memcheck_c_test_two\.c:\d+\)$/, output) end def test_can_run_multiple_times 2.times do ok = run_with_memcheck(<<~RUBY) RubyMemcheck::CTestOne.new.no_memory_leak RUBY assert(ok) end end def test_ruby_failure_without_errors ok = run_with_memcheck(<<~RUBY, raise_on_failure: false, spawn_opts: { out: "/dev/null", err: "/dev/null" }) foobar RUBY refute(ok) assert_empty(@test_task.reporter.errors) assert_empty(@output_io.string) rescue $stderr.puts(@test_task.reporter.errors) raise end def test_ruby_failure_with_errors error = assert_raises do run_with_memcheck(<<~RUBY, raise_on_failure: false, spawn_opts: { out: "/dev/null", err: "/dev/null" }) RubyMemcheck::CTestOne.new.memory_leak raise RUBY end assert_equal(RubyMemcheck::TestTaskReporter::VALGRIND_REPORT_MSG, error.message) output = @output_io.string assert_equal(1, @test_task.reporter.errors.length, output) refute_empty(output) assert_match(/^100 bytes in 1 blocks are definitely lost in loss record/, output) assert_match(/^ \*c_test_one_memory_leak \(ruby_memcheck_c_test_one\.c:\d+\)$/, output) end def test_test_helper_is_loaded Tempfile.create do |tempfile| ok = run_with_memcheck(<<~RUBY) File.write(#{tempfile.path.inspect}, $LOADED_FEATURES.join("\n")) RUBY assert(ok) assert_empty(@test_task.reporter.errors) assert_includes(tempfile.read, File.expand_path(File.join(__dir__, "../../lib/ruby_memcheck/test_helper.rb"))) end end def test_environment_variable_RUBY_MEMCHECK_RUNNING Tempfile.create do |tempfile| ok = run_with_memcheck(<<~RUBY, raise_on_failure: false) File.write(#{tempfile.path.inspect}, ENV["RUBY_MEMCHECK_RUNNING"]) RUBY assert(ok) assert_empty(@test_task.reporter.errors) assert_includes(tempfile.read, "1") end end def test_environment_variable_RUBY_FREE_AT_EXIT Tempfile.create do |tempfile| ok = run_with_memcheck(<<~RUBY, raise_on_failure: false) File.write(#{tempfile.path.inspect}, ENV["RUBY_FREE_AT_EXIT"]) RUBY assert(ok) assert_empty(@test_task.reporter.errors) assert_includes(tempfile.read, "1") end end def test_configration_binary_name build_configuration(binary_name: "ruby_memcheck_c_test_one") error = assert_raises do run_with_memcheck(<<~RUBY) RubyMemcheck::CTestOne.new.memory_leak RubyMemcheck::CTestTwo.new.memory_leak RUBY end assert_equal(RubyMemcheck::TestTaskReporter::VALGRIND_REPORT_MSG, error.message) output = @output_io.string assert_equal(1, @test_task.reporter.errors.length, output) refute_empty(output) assert_match(/^100 bytes in 1 blocks are definitely lost in loss record/, output) assert_match(/^ \*c_test_one_memory_leak \(ruby_memcheck_c_test_one\.c:\d+\)$/, output) end def test_configration_invalid_binary_name build_configuration(binary_name: "invalid_binary_name") error = assert_raises do run_with_memcheck(<<~RUBY) RubyMemcheck::CTestOne.new.memory_leak RUBY end assert_includes(error.message, "`invalid_binary_name`") end private def run_with_memcheck(code, raise_on_failure: true, spawn_opts: {}) raise NotImplementedError end def build_configuration( output_io: @output_io, **options ) @configuration = Configuration.new( output_io: @output_io, **options, ) build_test_task end def build_test_task raise NotImplementedError end end end ================================================ FILE: test/ruby_memcheck/suppressions/ruby.supp ================================================ { suppress memory_leak Memcheck:Leak ... fun:c_test_one_memory_leak ... } ================================================ FILE: test/ruby_memcheck/test_task_test.rb ================================================ # frozen_string_literal: true require "ruby_memcheck/shared_test_task_reporter_tests" module RubyMemcheck class TestTaskTest < Minitest::Test include SharedTestTaskReporterTests def setup Rake::FileUtilsExt.verbose_flag = false @output_io = StringIO.new build_configuration end private def run_with_memcheck(code, raise_on_failure: true, spawn_opts: {}) script = Tempfile.new script.write(<<~RUBY) require "ruby_memcheck_c_test_one" require "ruby_memcheck_c_test_two" #{code} RUBY script.flush ok = nil @test_task.ruby( "-I#{File.join(__dir__, "ext")}", script.path, **spawn_opts, ) do |ok_val, status| ok = ok_val if raise_on_failure && !ok raise "Command failed with status (#{status.exitstatus})" end end ok end def build_test_task @test_task = RubyMemcheck::TestTask.new(@configuration) end end end ================================================ FILE: test/ruby_memcheck/valgrind_error_test.rb ================================================ # frozen_string_literal: true require "test_helper" require "nokogiri" module RubyMemcheck class ValgrindErrorTest < Minitest::Test def setup @configuration = Configuration.new end def test_raises_when_suppressions_generated_but_not_configured output = ::Nokogiri::XML(<<~XML).at_xpath("//error") 0x1ab8 1 Leak_DefinitelyLost 48 bytes in 1 blocks are definitely lost in loss record 6,841 of 11,850 48 1 XML error = assert_raises do RubyMemcheck::ValgrindError.new(@configuration, [], output) end assert_equal(ValgrindError::SUPPRESSION_NOT_CONFIGURED_ERROR_MSG, error.message) end end end ================================================ FILE: test/ruby_memcheck_test.rb ================================================ # frozen_string_literal: true require "test_helper" class RubyMemcheckTest < Minitest::Test def setup RubyMemcheck.instance_variable_set(:@default_configuration, nil) end def test_config_sets_default_configuration config = RubyMemcheck.config assert_equal(config, RubyMemcheck.default_configuration) end def test_default_configuration_creates_new_configuration config = RubyMemcheck.default_configuration refute_nil(config) assert_equal(config, RubyMemcheck.default_configuration) end end ================================================ FILE: test/test_helper.rb ================================================ # frozen_string_literal: true $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) require "ruby_memcheck" require "minitest/autorun" if ENV["CI"] require "etc" ENV["NCPU"] ||= Etc.nprocessors.to_s require "minitest/parallel_fork" end