Skip to content

Segfault in connect under multithreaded use: cTinyTdsError/mTinyTds not registered with the Ruby GC #608

Description

@znppaed7

Environment

  • Ruby 3.4.10 (arm64-darwin, Apple Silicon)
  • tiny_tds 3.4.0 (also reproducible on 3.2.1 and 3.3.0)
  • FreeTDS 1.5.19 (Homebrew)
  • Web server: Falcon / async-http, --threaded (12 native threads)

Summary

Under a multithreaded Ruby server, TinyTds::Client.new intermittently crashes
the whole process with a segmentation fault during connect. The crash happens
while FreeTDS delivers its normal info messages (e.g. "Changed language
setting"), inside tinytds_msg_handler. In a single-threaded context (plain
ruby, or Falcon --forked) the same code runs fine.

Steps to reproduce

  1. Run a multithreaded Ruby HTTP server (e.g. falcon serve --threaded) that
    opens ESB/MS SQL connections per request via TinyTds::Client.new(host:, ...).
  2. Make requests that trigger connect.
  3. The process aborts with [BUG] Segmentation fault.

It does not reproduce in single-threaded execution — the same connect works
in isolated ruby -e 'TinyTds::Client.new(...)' and when the server runs with a
single thread/process.

Key backtrace (from the crash log)

libsybdb.5.dylib(_dblib_handle_info_message)
libsybdb.5.dylib(tds_connect)
libsybdb.5.dylib(tds_connect_and_login)
libsybdb.5.dylib(tdsdbopen)
tiny_tds.bundle(rb_tinytds_connect)
...
tiny_tds.bundle(tinytds_msg_handler)
tiny_tds.bundle(rb_tinytds_raise_error)
libruby(rb_exc_new_cstr)
libruby(rb_class_superclass -> rb_unexpected_type -> unexpected_type)
libruby(rb_obj_as_string -> rb_funcall -> callable_method_entry_or_negative) ← SIGSEGV

The value in x0 at the crash is a fragment of an ASCII string (e.g.
"read \"i\""), i.e. the C code is dereferencing a stale/freed pointer.

Root cause

ext/tiny_tds/tiny_tds_ext.c keeps global VALUEs that are not registered
with the GC
:

VALUE mTinyTds, cTinyTdsError;

void Init_tiny_tds() {
  mTinyTds      = rb_define_module("TinyTds");
  cTinyTdsError = rb_const_get(mTinyTds, rb_intern("Error"));
  init_tinytds_client();
  init_tinytds_result();
}

rb_tinytds_raise_error builds an exception on every FreeTDS message:

e = rb_exc_new2(cTinyTdsError, error.error);

Because cTinyTdsError/mTinyTds are not pinned via
rb_gc_register_address/rb_global_variable (unlike opt_escape_regex and
opt_escape_dblquote in client.c), the GC may move/reclaim the TinyTds
module and TinyTds::Error class while another native thread is mid-connect.
The C pointer then goes stale, and rb_exc_new2 crashes.

Proposed fix

void Init_tiny_tds() {
  mTinyTds      = rb_define_module("TinyTds");
  cTinyTdsError = rb_const_get(mTinyTds, rb_intern("Error"));

  rb_gc_register_address(&mTinyTds);
  rb_gc_register_address(&cTinyTdsError);

  init_tinytds_client();
  init_tinytds_result();
}

Related

Possibly the same family of multithreading crashes as #539 (also unregistered
GC globals / native code + concurrent threads).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions