_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 |
yes — stackprof_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.
_stackprof.intervalis aVALUEthat no mark function marks — freed by ordinary GC, then dereferencedSummary
_stackprof.interval(ext/stackprof/stackprof.c:122) holds aVALUEthatstackprof_gc_marknever marks. WhenStackProf.start(interval:)is given anything that isnot an immediate — a
Rational, aComplex, or any object responding toto_int— theobject is collected by ordinary GC while the field still points at it, and later calls
dereference the freed slot.
No
GC.compactis involved. This fires under a plainGC.start.This is latent for the common case and I want to be straight about that:
IntegerandFloatarguments are immediates on 64-bit, so the overwhelming majority of callers —anything passing a literal like
interval: 1000— are completely unaffected. Reaching itrequires 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
NUM2LONGraisingTypeError: no implicit conversion of Module into Integer— the freed slothad been recycled into a
Module, and the exception surfaces inside whatever unrelated codehappened to be allocating at the time.
Reproduction
Two steps that look incidental and are not, because a maintainer will reasonably simplify
them away:
iv = nilmatters. A live local is conservatively pinned by the machine-stack scan, sokeeping the reference makes the object immovable and uncollectable and the bug cannot
appear.
ToIntspecifically. A vacated slot is only reused by allocationsfrom 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.intervalstill pointed at them.Cause
The field,
stackprof.c:118-124:The store,
stackprof.c:251— note it stores the originalVALUE, becauseNUM2INT/NUM2LONGfall through torb_to_int, which converts a temporary:The mark function,
stackprof.c:871-886. It marksmetadata,out,framesandframes_buffer, and neitherintervalnormode:The later reads, all after the storing call has returned:
:400rb_hash_aset(results, sym_interval, _stackprof.interval)— hands the object back to Ruby in the results hash:704,:711NUM2LONG(_stackprof.interval)in the GC-sample timestamp arithmetic:846NUM2LONG(_stackprof.interval)instackprof_newobj_handler, the allocation tracepoint callback:913NUM2UINT(_stackprof.interval)when re-arming the timermodeis also unmarked and is fine — it can only ever hold one of fourrb_intern'dimmortal Symbols, compared by pointer identity with an
ArgumentErrorotherwise. Mentioningit so it is clear the report isn't just "grep found unmarked fields".
Affected versions
master@ HEADstackprof_gc_markis unchanged andintervalis still aVALUEat:122Suggested fix
Rather than adding
rb_gc_mark(_stackprof.interval), stop storing aVALUEat all. Everyconsumer already wants a C integer —
:704,:711and:846callNUM2LONG,:913callsNUM2UINT— so theVALUEexists only to be re-converted on each read:with
:400becomingLONG2NUM(_stackprof.interval)and theRTEST(_stackprof.interval)guard at
:846becoming_stackprof.interval(nil-ness is currently meaningful —:243setsit to
Qnilformode: :object, which0expresses 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
VALUEdereference out ofthe allocation-tracepoint callback entirely. It also makes the invalid-type error surface at
StackProf.startrather 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
.sootherwise produces a phantom result (it did once here — anearlier attempt of mine loaded the installed gem instead of the patched build and reported
the fix as not working):
.sosha256SAFE=1)SAFE=0)168f911ee92fe7b0…ac644173462ec942…Behaviour preserved:
StackProf.run(mode: :wall, interval: 1000)still reportsinterval=1000as anIntegerwith samples collected;mode: :object, interval: 1stillreports
interval=1and 5001 samples. And the invalid-type error now surfaces where you wouldwant it —
StackProf.start(interval: Object.new)raisesTypeError: no implicit conversion of Object into Integerat the call rather than from insidea later callback.
test/test_stackprof.rbon the patch: 19 runs, 162 assertions. The only failures aretest_walltimeandtest_gc, and both fail intermittently on the unpatched build too in thesame 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
Reproduced identically on 0.2.26 in the same container. Linux/aarch64; the mechanism has no
platform dependency beyond
Integer/Floatbeing immediates, which holds on any 64-bit build.