Skip to content

_stackprof.interval is a VALUE that no mark function marks — freed by ordinary GC, then dereferenced #244

Description

@jeremy

_stackprof.interval is a VALUE that no mark function marks — freed by ordinary GC, then dereferenced

Summary

_stackprof.interval (ext/stackprof/stackprof.c:122) holds a VALUE that
stackprof_gc_mark never marks. When StackProf.start(interval:) is given anything that is
not an immediate — a Rational, a Complex, or any object responding to to_int — the
object is collected by ordinary GC while the field still points at it, and later calls
dereference the freed slot.

No GC.compact is involved. This fires under a plain GC.start.

This is latent for the common case and I want to be straight about that: Integer and
Float arguments are immediates on 64-bit, so the overwhelming majority of callers —
anything passing a literal like interval: 1000 — are completely unaffected. Reaching it
requires passing a heap numeric or a duck-typed object. Filing it because the field is
genuinely unrooted, the accepted-input surface is wider than it looks, and one of the read
sites is an allocation-tracepoint callback.

The failure is not reliably a crash. The most informative symptom I saw was stackprof's own
NUM2LONG raising TypeError: no implicit conversion of Module into Integer — the freed slot
had been recycled into a Module, and the exception surfaces inside whatever unrelated code
happened to be allocating at the time.

Reproduction

# sp_interval.rb -- SAFE=1 is the control (immediate Fixnum), SAFE=0 the test.
# No GC.compact anywhere; ordinary GC only.
require "stackprof"

class ToInt
  def initialize(n) = @n = n
  def to_int = @n
end

safe = ENV["SAFE"] == "1"
10.times do |i|
  iv = safe ? 1000 : ToInt.new(1000 + i)
  StackProf.start(mode: :wall, interval: iv, raw: false)
  iv = nil                                     # drop the only Ruby reference
  5.times { 4000.times { ToInt.new(7) }; GC.start }   # ordinary GC + same-pool churn
  StackProf.stop
end
puts "SAFE=#{safe} survived"
$ SAFE=1 ruby sp_interval.rb     # control
SAFE=true survived               # exit 0, 3/3

$ SAFE=0 ruby sp_interval.rb     # test
Segmentation fault               # exit 139, 3/3

Two steps that look incidental and are not, because a maintainer will reasonably simplify
them away:

  • iv = nil matters. A live local is conservatively pinned by the machine-stack scan, so
    keeping the reference makes the object immovable and uncollectable and the bug cannot
    appear.
  • The churn allocates ToInt specifically. A vacated slot is only reused by allocations
    from the same GC size pool, so filling with a different-sized object leaves the freed slot
    holding its original bytes and the run reports clean.

Rate: this is not probabilistic once the object is actually collected — 3/3 runs at 10
trials each. A separate probe that never touched the field measured the precondition directly:
10/10 objects freed while _stackprof.interval still pointed at them.

Cause

The field, stackprof.c:118-124:

static struct {
    ...
    VALUE mode;
    VALUE interval;
    VALUE out;
    VALUE metadata;

The store, stackprof.c:251 — note it stores the original VALUE, because
NUM2INT/NUM2LONG fall through to rb_to_int, which converts a temporary:

    _stackprof.interval = interval;

The mark function, stackprof.c:871-886. It marks metadata, out, frames and
frames_buffer, and neither interval nor mode:

stackprof_gc_mark(void *data)
{
    if (RTEST(_stackprof.metadata))
	rb_gc_mark(_stackprof.metadata);

    if (RTEST(_stackprof.out))
	rb_gc_mark(_stackprof.out);

    if (_stackprof.frames)
	st_foreach(_stackprof.frames, frame_mark_i, 0);

    int i;
    for (i = 0; i < _stackprof.buffer_count; i++) {
        rb_gc_mark(_stackprof.frames_buffer[i]);
    }
}

The later reads, all after the storing call has returned:

line context
:400 rb_hash_aset(results, sym_interval, _stackprof.interval) — hands the object back to Ruby in the results hash
:704, :711 NUM2LONG(_stackprof.interval) in the GC-sample timestamp arithmetic
:846 NUM2LONG(_stackprof.interval) in stackprof_newobj_handler, the allocation tracepoint callback
:913 NUM2UINT(_stackprof.interval) when re-arming the timer

mode is also unmarked and is fine — it can only ever hold one of four rb_intern'd
immortal Symbols, compared by pointer identity with an ArgumentError otherwise. Mentioning
it so it is clear the report isn't just "grep found unmarked fields".

Affected versions

version affected
0.2.26 yes
0.2.28 (latest release) yes
master @ HEAD yesstackprof_gc_mark is unchanged and interval is still a VALUE at :122

Suggested fix

Rather than adding rb_gc_mark(_stackprof.interval), stop storing a VALUE at all. Every
consumer already wants a C integer — :704, :711 and :846 call NUM2LONG, :913 calls
NUM2UINT — so the VALUE exists only to be re-converted on each read:

-    VALUE interval;
+    long interval;          /* 0 == unset; every consumer wants an integer anyway */
-    _stackprof.interval = interval;
+    _stackprof.interval = NIL_P(interval) ? 0 : NUM2LONG(interval);

with :400 becoming LONG2NUM(_stackprof.interval) and the RTEST(_stackprof.interval)
guard at :846 becoming _stackprof.interval (nil-ness is currently meaningful — :243 sets
it to Qnil for mode: :object, which 0 expresses just as well).

This is strictly better than marking it: it removes an unrooted reference instead of rooting
one, does the conversion once instead of per-sample, and takes the VALUE dereference out of
the allocation-tracepoint callback entirely. It also makes the invalid-type error surface at
StackProf.start rather than from inside a later callback.

I built this and ran it

Red/green on the reproducer above, with the loaded binary checksummed in the same step that
built it, because a stale .so otherwise produces a phantom result (it did once here — an
earlier attempt of mine loaded the installed gem instead of the patched build and reported
the fix as not working):

build .so sha256 control (SAFE=1) test (SAFE=0)
0.2.28 as released 168f911ee92fe7b0… clean 3/3 SIGSEGV 3/3 (rc 139)
with the patch above ac644173462ec942… clean 3/3 clean 3/3

Behaviour preserved: StackProf.run(mode: :wall, interval: 1000) still reports
interval=1000 as an Integer with samples collected; mode: :object, interval: 1 still
reports interval=1 and 5001 samples. And the invalid-type error now surfaces where you would
want it — StackProf.start(interval: Object.new) raises
TypeError: no implicit conversion of Object into Integer at the call rather than from inside
a later callback.

test/test_stackprof.rb on the patch: 19 runs, 162 assertions. The only failures are
test_walltime and test_gc, and both fail intermittently on the unpatched build too in the
same container
— 3 runs of each tree gave 4 failures on pristine versus 3 on the patch, with
an identical failing set. They are sampling-tolerance assertions with a ±25 bound, so I read
them as environmental (virtualised aarch64) rather than related to this change — #190 looks
like the same flakiness. Worth confirming on your CI.

Happy to open this as a PR if the approach looks right.

Environment

ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux]
stackprof 0.2.28, installed from source (gem install --platform=ruby)
loaded: /usr/local/bundle/gems/stackprof-0.2.28/lib/stackprof/stackprof.so
        sha256 168f911ee92fe7b0…

Reproduced identically on 0.2.26 in the same container. Linux/aarch64; the mechanism has no
platform dependency beyond Integer/Float being immediates, which holds on any 64-bit build.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions