Mark Result's fieldTypes so the GC can see it - #33
Conversation
mysql2_result_wrapper is xmalloc'd, so every VALUE in it has to be marked by hand. fieldTypes was declared in result.h but never added to rb_mysql_result_mark, so nothing keeps the Array alive. The Array is freed inside the call that allocates it: the fill loop calls rb_mysql_result_fetch_field_type, which allocates Strings and can trigger a GC, and the subsequent rb_ary_store then writes through a freed object slot. No compaction involved -- ordinary GC is enough. Same fix is open upstream as brianmario#1454.
CI is red, and all 6 failures are pre-existing on
|
| Workflow | Failing job | Message |
|---|---|---|
| RuboCop | build |
This request has been automatically failed because it uses a deprecated version of actions/cache: v1 |
| Build | macos-latest ruby 2.4 mariadb |
Error: CRuby < 2.6 does not support macos-arm64. |
| Build | macos-latest ruby 2.4 mysql |
Error: CRuby < 2.6 does not support macos-arm64. |
Neither is about this diff. The first is GitHub force-failing actions/cache@v1; the second is macos-latest having moved to arm64, where no Ruby older than 2.6 exists.
The remaining 36 jobs on this PR are not red — they are stuck queued and will never start, because their runner images are retired: ubuntu-16.04, ubuntu-18.04, ubuntu-20.04, centos:7, fedora:latest, fedora:rawhide. They will not go green either.
They fail on the base too. The PR's base commit is b5766f5d296745d6aed014c8c0b7d82ef149ade1 (Merge remote-tracking branch 'brianmario/master' into force_latin1_to_utf8, the tip of force_latin1_to_utf8). I pushed that commit unmodified as a scratch branch to run CI on it, and got exactly the same result:
- RuboCop — https://github.com/basecamp/mysql2/actions/runs/31022414556 — job
buildfailed, with the identicalactions/cache: v1message - Build — https://github.com/basecamp/mysql2/actions/runs/31022414301 —
macos-latest ruby 2.4 mariadbandmacos-latest ruby 2.4 mysqlfailed, both withError: CRuby < 2.6 does not support macos-arm64. - Container — https://github.com/basecamp/mysql2/actions/runs/31022416074 — queued, never started
Those runs are attached to commit b5766f5 and stay reachable at the URLs above; the scratch branch itself has served its purpose and is being removed.
So: 3 red jobs on the base, the same 3 red jobs here, same messages. Nothing in this diff moved CI in either direction.
Separately: the patch itself, verified 3/3 red → green
A different run from the CI above, done from the exact commits this PR points at rather than from a working copy. Both trees were exported with git archive from the pushed objects, and their tree SHAs match what GitHub reports:
| Arm | Commit | Tree SHA |
|---|---|---|
| red (base, unpatched) | b5766f5d296745d6aed014c8c0b7d82ef149ade1 |
87014bd8ffbe4e939b07ac6e15a6d0bfaa5e30a7 |
| green (this PR's head) | f8fbd714684120732d39402f4a1311622a3ecd57 |
25c163652f8fe5b14074a2d79291643e603c46f6 |
Both compiled and exercised inside a single container step, so the .so under test cannot drift from its source. Ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux], MySQL 9.6.0. The reproducer is public API only — client.query(...), then one first-ever field_types call with GC.stress on across just that call — no Fiddle and no raw memory reads, so a crash can only come from the extension.
BUILD OK red so-sha256=c361d99e8cb7cf37 result.c-sha256=cdb9270eb7949b72
BUILD OK green so-sha256=e7e8e5e23e1b6951 result.c-sha256=dab3036feb58e69c
red stress run1 exit=134 Aborted
red stress run2 exit=134 Aborted
red stress run3 exit=134 Aborted
red CONTROL(GC=off) exit=0 SURVIVED
green stress run1 exit=0 SURVIVED
green stress run2 exit=0 SURVIVED
green stress run3 exit=0 SURVIVED
green CONTROL(GC=off) exit=0 SURVIVED
The GC=off control is what makes the red arm interpretable: with the collector disabled the unpatched build completes normally, so the crash is the GC reclaiming the unmarked Array, not a broken build or a bad query.
The red arm aborts at the field_types call itself:
mysql2_repro.rb:46: [BUG] try to mark T_NONE object (obj: 0x0000ffff7497ffa8 T_NONE/, parent: 0x0000ffff74983fb8 T_IMEMO/<env> )
ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux]
...
mysql2_repro.rb:46:in '<main>'
where line 46 is types = res.field_types. This fork pins with rb_gc_mark and has no compact function, so the one added line is the whole fix here — unlike upstream (brianmario#1454), which needs both the movable mark and the dcompact entry.
bc3, launchpad and queenbee all ship this fork as
mysql2 0.5.4.latin1utf8, so this is the copy that actually matters to us. Same defect as upstream, filed there as brianmario#1454.mysql2_result_wrapperis xmalloc'd, so the collector neither scans it nor updates it — everyVALUEin it has to be marked by hand.fieldTypesis declared inresult.h:9but missing fromrb_mysql_result_mark, which marks exactlyfields,rows,encoding,clientandstatement. Nothing else marks it.This fork is pre-TypedData (
Data_Get_Struct, non-movablerb_gc_mark, no compact function), so unlike upstream it needs one line rather than two. Compaction is irrelevant here either way — ordinary GC is enough.The window is inside the allocating call
rb_mysql_result_fetch_field_typesallocates the Array and stores it inwrapper->fieldTypes; the fill loop then callsrb_mysql_result_fetch_field_typeper column, which allocates Strings and can trigger a GC;rb_ary_storeafterwards writes through a freed object slot. So it's a write into whatever now occupies that slot, not only a bad read.Reproducing
Public API only — no
Fiddle, no raw memory access, so a crash can only come from the extension:Both trees built inside the same step that ran the test, so the artifact can't drift from the source:
GC.stressonly makes it deterministic. Without it — ordinary GC, allocation churn between calls, noGC.start, no compaction — upstream master segfaults within the first 25 calls, 5/5, while the patched build completes 60,000 calls 3/3.Ruby 4.0.6, MySQL 9.6, in a container.
Reachability
Result#field_typesis public and is whateach(as: :hash)type-casting goes through, so anything reading rows off this driver is on the path. Found while sweeping our native gems for danglingVALUEs.