Repository navigation
Conversation
Database#busy_timeout documents that its wait holds the GVL. SQLite sleeps while holding the connection's mutex, so a sleep that released the GVL would let another thread enter the connection and block on that mutex with the GVL held, and a sleep that checked for interrupts would let a Thread#raise unwind through SQLite and leave the mutex locked. - A busy_timeout wait doesn't let another thread run. - A thread using a connection while another thread waits on it doesn't deadlock (in a child process, since a deadlock stops every thread). - A Thread#raise aimed at a waiting thread leaves the connection usable from another thread (also in a child process). The child processes use spawn, not fork, so these also run on Windows, where SQLite's Sleep resolves to the Ruby DLL's rb_w32_Sleep (upstream issue 766). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SQLite's Windows VFS sleeps through the "Sleep" entry of its system-call table (winLock's lock retries, winRetryIoerr, and the default busy handler behind Database#busy_timeout), while holding the connection's mutex. Linked into this extension, that "Sleep" resolves against the Ruby DLL, which exports a "Sleep" forwarding to rb_w32_Sleep (win32/mkexports.rb turns the "#define Sleep" in include/ruby/win32.h into an export). rb_w32_Sleep releases the GVL and checks for interrupts, so another Ruby thread could enter the connection and block on its mutex with the GVL held, or a Thread#raise could unwind through SQLite and leave the mutex locked. Either deadlocks the process. At load time, look up kernel32's Sleep with GetProcAddress (naming Sleep in the source would get the ruby/win32.h macro and the same import) and install it with each Windows VFS's xSetSystemCall. Harmless when SQLite is a separate DLL, whose Sleep already comes from kernel32. Fixes upstream issue 766 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n if it can't be installed Review follow-up: - CHANGELOG: limit both entries to builds with SQLite linked into the extension (the precompiled gems and the default build); list what upgraders will notice (same-process lock holders can't run, so the wait ends in BusyException; interrupts and Timeout take effect only when the wait ends; the VFS's own sharing-violation retries hold the GVL too); and qualify the busy_handler_timeout= advice: it lets other threads run only if they don't use the waiting connection. - Database#busy_timeout rdoc: state that the wait holds the GVL and defers interrupts. Database#busy_handler_timeout= rdoc: add the same shared-connection caveat. - sqlite3.c: give the real reason for GetProcAddress (a direct reference would bind to the Ruby-DLL import; the function-like macro isn't what matters), list winClose's retry and the WAL read retry, note that xSetSystemCall is a testing aid and which test guards the fix, and warn at load time if no VFS accepts the override instead of failing silently. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only --enable-system-libraries builds (which SQLCipher builds imply) use SQLite as a separate DLL and already had kernel32's Sleep. CI's mswin job uses that option, but a vendored mswin build would be affected like the default build; the fix runs there too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #766.
Problem
On Windows, SQLite's own sleeps release the GVL and can deliver interrupts while SQLite holds a connection's mutex. That contradicts
Database#busy_timeout's documentation ("holds the GVL") and can deadlock the whole process:Thread#raise,Thread#killor Timeout lands in the sleep and unwinds through SQLite's C frames, leaving the mutex locked for the next caller.We hit the first in production: whole-process freezes at 0% CPU in a multi-process app. A full-memory dump showed the GVL owned by a thread in
EnterCriticalSectionon a connection mutex, and that mutex owned by a thread inrb_w32_Sleep, inside SQLite'swinLockretry, waiting for the GVL. The write-up is in #766 and in elanthia-online/lich-5#2254.Cause
"Sleep"entry of its system-call table (osSleep) while holdingdb->mutex:winLock's PENDING-lock retry (osSleep(1)between 3 attempts), which happens whenever another process holds the lock, even withbusy_timeout(0);winRetryIoerr;busy_timeout(N)(sqliteDefaultBusyCallback→winSleep);winClose's retry.mkmflinks Ruby's import library before kernel32. The Ruby DLL exports aSleepsymbol that forwards torb_w32_Sleep:win32/mkexports.rbturns#define Sleep(msec) rb_w32_Sleep(msec)ininclude/ruby/win32.hinto an export. So that"Sleep"slot resolves torb_w32_Sleep.Sleepfrom the Ruby DLL: 13 builds from 1.3.13 to 2.10.2.--enable-system-libraries) gets kernel32'sSleep.rb_w32_Sleep(thread_win32.c) runs the sleep inBLOCKING_REGION, which releases the GVL and, throughunblock_function_set, runsRUBY_VM_CHECK_INTS.Fix
In
Init_sqlite3_native, on Windows, aftersqlite3_initialize(), look up kernel32'sSleepwithGetProcAddressand install it in SQLite's system-call table throughxSetSystemCall.GetProcAddress: inside the extension, writingSleepwould give you theruby/win32.hmacro and the same Ruby import, so the address has to be looked up at run time.win32,win32-longpath,win32-none,win32-longpath-none) share one table. Each is tried in case some are compiled out.Sleepalready is kernel32's.#if defined(_WIN32) && SQLITE_VERSION_NUMBER >= 3007006, sincexSetSystemCallneeds VFSiVersion3. Nothing changes on other platforms.Behaviour change
This applies only to builds with SQLite linked into the extension: the precompiled gems and the default build. Builds with
--enable-system-libraries(which SQLCipher builds imply) already used kernel32'sSleep. That includes CI's mswin job, but a vendored mswin build would be affected like the default build, and the fix runs there too. On those Windows builds, SQLite's waits now hold the GVL, as documented and as on Linux and macOS. Upgraders will notice:busy_timeoutwait. If another connection in the same process holds the lock (a connection pool, say), its thread can't run to release it, so the wait ends inBusyExceptionafter the full timeout.Timeout.timeout,Thread#raise,Thread#killand Ctrl-C take effect only once the wait ends. ATimeoutshorter than the busy timeout ends inBusyExceptionrather thanTimeout::Error.winRetryIoerr), and up to 200 ms when closing (winClose).busy_handler_timeout=doesn't help shared connections. It lets other threads run during the wait only if they don't use the waiting connection. SQLite runs the Ruby handler while holding the connection's mutex, so a thread that calls into the connection meanwhile hangs the process, on every platform. If threads share a connection, keepbusy_timeout, or give each thread its own connection.These are in the CHANGELOG, in
Database#busy_timeout's rdoc (GVL and interrupts), and inDatabase#busy_handler_timeout='s rdoc (the shared-connection caveat).Alternatives considered
Sleepand install it withxSetSystemCall. It doesn't depend on link order or toolchain.xSetSystemCallis documented as a testing aid, and the set of overridable calls may change between SQLite releases. Mitigations: the return codes are checked, and the extension warns at load time if no VFS accepted the override.test/test_sqlite_sleep.rbalso fails on the Windows CI jobs if the override stops working, which the tests-only run demonstrated.-lkernel32right after the vendoredlibsqlite3.ainextconf.rb. A MinGW cross-link test showed this binds kernel32'sSleep.--enable-system-librariesdoes). ItsSleepcomes from kernel32.Option 1 can also be combined with 2 later, if the maintainers prefer to fix the link and keep the override as a guard.
Tests (
test/test_sqlite_sleep.rb)busy_timeoutwait doesn't let another Ruby thread run (a ticker thread counts at most a tick or two over a 300 ms wait).Thread#raiseaimed at a thread in abusy_timeoutwait leaves the connection usable from another thread.Tests 2 and 3 run in a child process with a 30 s timeout, because a deadlock stops every thread of the process. They use
spawnrather thanfork(unlikeassert_connection_usable_from_another_thread), so they run on Windows too.Checking that the tests catch the bug. The bug can't be reproduced natively on Linux, so I used a small test-only
LD_PRELOADshim. It routesnanosleepcalls made fromsqlite3_native.sothroughrb_thread_call_without_gvl, which releases the GVL and checks interrupts, asrb_w32_Sleepdoes. With it, all three tests fail as they would on Windows:Without the shim, all three pass.
Test 1 is skipped on non-CRuby. TruffleRuby has no GVL, so other threads legitimately run during the wait. The two subprocess tests run everywhere.
Results:
test (windows, 3.2–4.0, disable)(vendored SQLite)test source (windows, 3.2–4.0, disable)x64-mingw-ucrt 3.2–4.0(native gem tests)test (windows, *, enable),mswin(both--enable-system-libraries: SQLite as a separate DLL)On the Ruby 4.0 Windows job without the fix, the failures were:
Skip counts are the same in both runs, so the tests ran rather than being skipped. Jobs that link SQLite as a separate DLL pass either way, because that DLL's
Sleepcomes from kernel32.On my fork, at
15e4a37(one more docs-only commit since): 131 jobs pass and 3 fail, none from this change.sqlcipher (windows, mingw): the MSYS2 package install fails, the same as onmain.sqlcipher (windows, 3.3):TestCollation#test_replacing_a_collation_releases_the_previous_comparator, which fails the same way onmainat this PR's base commit and on the v2.10.2 tag.arm64-darwin 3.3: an artifact download timed out before any test ran. It passed on re-run.On the Ruby 4.0 Windows job, the log has no "could not point SQLite's Sleep" warning, so the override installed.
One earlier run also had a single failure of the existing
test_busy_timeoutwall-clock check (0.996 >= 1.0) onx86_64-linux-gnu 4.0.Notes
getpid(rb_w32_getpid), which the extension imports too. It's harmless and left alone.Sleepto the Ruby export as well. That's outside SQLite and not touched here.