Skip to content

Unrelated thread causes query cancellation #598

Open

Description

@owst

With the following code, we see the query result being printed, followed by awake when the external sleep finishes:

require 'tiny_tds'

client = TinyTds::Client.new(
  username: ENV.fetch("SQLSERVER_USERNAME"),
  password: ENV.fetch("SQLSERVER_PASSWORD"),
  host: ENV.fetch("SQLSERVER_HOST"),
  port: ENV.fetch("SQLSERVER_PORT").to_i,
  database: ENV.fetch("SQLSERVER_DATABASE")
)

external_sleep_thread = Thread.new do
  puts `sleep 0.3; echo awake` # I was doing some external instrumentation in my actual code
end

p client.execute("WAITFOR DELAY '00:00:00:200'; SELECT 42 AS TheAnswer").to_a

external_sleep_thread.join
[{"TheAnswer" => 42}]
awake

If we change sleep 0.3 to sleep 0.1 (i.e. shorter than the delay in the SQL) then we unexpectedly no longer see the query result:

awake
[]

it appears that the query is being cancelled when the external sleep returns. Having dug into the code, it appears that this is likely caused by nogvl_dbresults:

static RETCODE nogvl_dbresults(DBPROCESS *client)
{
int retcode = FAIL;
nogvl_setup(client);
retcode = NOGVL_DBCALL(dbresults, client);
nogvl_cleanup(client);
return retcode;
}

which calls rb_thread_call_without_gvl passing a "unblock_function" (ubf) of dbcancel_ubf:

#define NOGVL_DBCALL(_dbfunction, _client) ( \
(RETCODE)(intptr_t)rb_thread_call_without_gvl( \
(void *(*)(void *))_dbfunction, _client, \
(rb_unblock_function_t*)dbcancel_ubf, _client ) \
)

it seems that the pg gem used to have similar code: https://groups.google.com/g/ruby-pg/c/5_ylGmog1S4/m/c69d__aNhsUJ which was described as "buggy" 😄

I'm not sure of the exact mechanism, but possibly the SIGCHLD from the external sleep is triggering the ubf, which cancels the query?

N.b. it seems that the pg and mysql2 gems both call rb_thread_call_without_gvl with UBF_IO:

https://github.com/ged/ruby-pg/blob/fc75120b173e43b08edf2e6936bf4ba9c196a47f/ext/gvl_wrappers.h#L63

https://github.com/brianmario/mysql2/blob/b009d7e114729cbae5bef069a1033dd78acf7745/ext/mysql2/result.c#L559

It seems like this gem should also use UBF_IO to avoid undesired query cancellations, albeit I suspect that would mean that a pending query couldn't be cancelled by SIGINT.

This was tested on ruby 3.4.5 (2025-07-16 revision 20cda200d3) +PRISM [arm64-darwin24] against tiny_tds 3.2.1

Activity

  1. andyundso commented on Nov 2, 2025

    @andyundso
    Member

    Hi @owst!

    Thanks for the report.

    It is a possibility that this is a side-effect of Ruby's signal handling. You now talk about undesired query cancellations, but generally the OS does not send unnecessary signals around. Most of the time it will be a request to stop whatever you are doing, and in that case we want to cancel the query.

    FreeTDS, the underlying library, also requires us to either acknowledge all results or to cancel. Means if you want to re-use the same client to do another query, then the previous command batch has to be empty.

    That said, I am working (from time to time) on a new major release in a separate branch. Currently, in v3, when you call execute, the results are not consumed directly, but only when you call to_a. It is meant to lazy-load results from a server, but internally it introduces a bunch of issues with the GC and also the GVL, so I decided to remove it in v4. When you call execute in v4, all results are fetched from the server directly. So your issue should no longer be possible, unless in potentially very rare cases when the loading of results happens in the same time as the signal is sent by the OS. But I'll test your code on the v4 branch and see what happens.

  2. andyundso commented on Dec 18, 2025

    @andyundso
    Member

    Hi @owst

    I can confirm that in v4, your example will yield the correct result. v4 is still a couple of months away and I'll keep the issue open until then.

  3. emailrhoads commented on Aug 19, 2026

    @emailrhoads

    Just wanted to chime and say we also encountered this behavior. I know v4 is coming soon :) , but I put up #607 to try and fix in the meantime.

    Here is my understanding of how this happens for us in practice

    1. TinyTDS starts a query and waits for results on main thread
    2. When it is waiting for results, it releases the GVL and sets the unblocking function (UBF) to db_cancel <-- the bug IMO
    3. GVL is free, other threads run (AR connection reaper, New Relic) and will do work while waiting for the wire
    4. One of those threads runs and spawns a child - for example NewRelic memory sampler which can call ps -o rss #{pid}
    5. That child exits and sends SIGCHLD. MRI handles and traps on main thread and sees main is outside GVL, calls UBF
    6. UBF in this case is dbcancel() which causes empty success.

    This allows us to leave #each as/is and minimize the scope of change

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