Skip to content

Mark Result's fieldTypes so the GC can see it - #33

Merged
jeremy merged 1 commit into
force_latin1_to_utf8from
mark-field-types
Aug 6, 2026
Merged

Mark Result's fieldTypes so the GC can see it#33
jeremy merged 1 commit into
force_latin1_to_utf8from
mark-field-types

Conversation

@jeremy

@jeremy jeremy commented Aug 5, 2026

Copy link
Copy Markdown
Member

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_wrapper is xmalloc'd, so the collector neither scans it nor updates it — every VALUE in it has to be marked by hand. fieldTypes is declared in result.h:9 but missing from rb_mysql_result_mark, which marks exactly fields, rows, encoding, client and statement. Nothing else marks it.

This fork is pre-TypedData (Data_Get_Struct, non-movable rb_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_types allocates the Array and stores it in wrapper->fieldTypes; the fill loop then calls rb_mysql_result_fetch_field_type per column, which allocates Strings and can trigger a GC; rb_ary_store afterwards 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:

res = client.query("select 1 as a, 'x' as b, 2.5 as c, now() as d")
GC.stress = true
types = res.field_types      # first-ever call on this Result
GC.stress = false
raise "not an Array: #{types.class}" unless types.is_a?(Array)

Both trees built inside the same step that ran the test, so the artifact can't drift from the source:

                     fork HEAD   with this patch
GC.stress, 1 call    3/3 abort   3/3 pass
GC off (control)     3/3 pass    3/3 pass

GC.stress only makes it deterministic. Without it — ordinary GC, allocation churn between calls, no GC.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_types is public and is what each(as: :hash) type-casting goes through, so anything reading rows off this driver is on the path. Found while sweeping our native gems for dangling VALUEs.

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.
Copilot AI balanced review requested due to automatic review settings August 5, 2026 15:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@jeremy

jeremy commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

CI is red, and all 6 failures are pre-existing on force_latin1_to_utf8

This branch changes one line in ext/mysql2/result.c and nothing else. Here is why the checks are red anyway, in a form that can be checked independently.

The failing checks on f8fbd714684120732d39402f4a1311622a3ecd57 — 3 distinct jobs, each appearing twice because both the push and the pull_request event fire the same workflows:

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:

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.

@jeremy
jeremy merged commit 9449e1e into force_latin1_to_utf8 Aug 6, 2026
2 of 43 checks passed
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.

2 participants