Skip to content

Free the non-blocking error array before a raise - #613

Open
navidemad wants to merge 1 commit into
rails-sqlserver:masterfrom
navidemad:fix-nonblocking-errors-leak
Open

navidemad wants to merge 1 commit into
rails-sqlserver:masterfrom
navidemad:fix-nonblocking-errors-leak

Conversation

@navidemad

@navidemad navidemad commented Oct 15, 2026 •

Copy link
Copy Markdown

Problem

nogvl_cleanup raises the first SQL Server error with rb_exc_raise. A raise from C is a longjmp: it leaves the function at once. Thus the free(userdata->nonblocking_errors) call after the loop does not run.

nogvl_setup allocates this array with malloc for ERRORS_STACK_INIT_SIZE errors. The array is 4,136 bytes on 64-bit Linux. Each query that fails leaks one array, because the next nogvl_setup call replaces the pointer.

Fix

  1. Copy the collected errors into a buffer that Ruby manages (ALLOCV_N).
  2. Free the malloc array and reset its fields.
  3. Process the copy as before: call the message handler for info messages, then raise the first error.

If the message handler or the raise leaves the function, the GC frees the copy. The order of the messages does not change. The raised exception does not change.

Measurements

The tool is ruby_memcheck 3.0.1. It runs Ruby under Valgrind and removes the false positives that Ruby causes. A "definitely lost" block is a block with no pointer to it at exit.

Failing queries master This PR
5 53,559 bytes definitely lost 32,879 bytes definitely lost
20 115,599 bytes definitely lost 32,879 bytes definitely lost

On master, 15 more failing queries add 62,040 bytes. That is 4,136 bytes for each error. With this PR, the total stays the same for any number of errors. The 32,879 bytes that remain come from the one-time dbinit allocations (the FreeTDS context).

Reproduction script
# MODE=query_error N=20 ruby_memcheck -Ilib leak_repro.rb
require "tiny_tds"

client = TinyTds::Client.new(host: "mssql", username: "tinytds", password: "", database: "tinytdstest")
Integer(ENV.fetch("N", "10")).times do
  client.execute("SELECT * FROM table_that_does_not_exist").do
rescue TinyTds::Error
  nil
end
client.close

Validation

  • bundle exec rake test: 123 tests, 535 assertions, 0 failures, 0 errors.
  • The full test suite under ruby_memcheck (binary_name: "tiny_tds") shows no invalid read or write, and no leak from nogvl_setup. The remaining leaks come from failed logins. Raise login errors after dbopen returns #614 fixes them.
  • astyle --options=astyle.conf does not change the C files.

Environment: Ruby 4.0.7 on Linux aarch64, FreeTDS 1.5.19 built from source, SQL Server 2022 CU27 (mcr.microsoft.com/mssql/server:2022-latest), Valgrind 3.24.0.

Note

The draft v4 branch (#603) moves nogvl_cleanup to client.c with the same body. That branch has the same leak.

nogvl_cleanup raised the first SQL Server error before it freed the
malloc'd nonblocking_errors array. Each error leaked the array (4,136 bytes).

Copy the errors into a Ruby-managed buffer. Free the array before any raise.
@navidemad navidemad mentioned this pull request Oct 15, 2026
5 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant