Samuel Williams Friday, 14 August 2026

Being an account of a Fiber which disappeared while Ruby was still returning to it, as recorded by Dr. Claude Watson.

Chapter I: The Crash After the Error

The telegram arrived at Baker Street with two failures recorded upon it. The first was unremarkable:

EOFError: unexpected end of chunked response

The second was considerably less so:

fiber_pool_stack_release
fiber_stack_release
fiber_switch
rb_fiber_start

“A server truncated an HTTP response,” I said. “Async::HTTP raised EOFError, its task unwound, and Ruby crashed. Shall we begin with the HTTP parser?”

Holmes placed the two traces side by side. “What should happen when a response ends early, Watson?”

“The task should fail with EOFError.”

“Then the first failure is expected. The mystery is why reporting it disturbed Ruby’s Fiber machinery.”

The original report described an intermittent crash on Ruby 3.4.10. A busy asynchronous workload made it visible, but the C backtrace pointed beneath sockets and HTTP.

“Then we must separate the circumstance which revealed the defect from the mechanism which caused it,” Holmes said.

Chapter II: Making Chance Repeat Itself

The reproduction sent many deliberately truncated responses through Async::HTTP. Sometimes every task failed cleanly. Sometimes the Ruby process crashed.

“A timing problem,” I suggested. “Perhaps two tasks are modifying the same Fiber.”

“Perhaps,” said Holmes. “But intermittency tells us only that the necessary ordering is uncommon. We need a witness who stops the program at the first offence, not at the later commotion.”

We rebuilt Ruby with AddressSanitizer. The uncertain crash became a precise accusation:

ERROR: AddressSanitizer: heap-use-after-free
READ of size 1
    #0 fiber_switch .../cont.c
freed by thread here:
    #1 rb_gc_impl_free .../gc/default/default.c
    #2 ruby_sized_xfree .../gc.c
    #3 ruby_xfree .../gc.c
    #4 cont_free .../cont.c
    #5 fiber_free .../cont.c
    #6 rb_data_free .../gc.c
    #7 rb_gc_obj_free .../gc.c
    #8 gc_sweep_plane .../gc/default/default.c
    #9 gc_sweep_page .../gc/default/default.c
    #10 gc_sweep_step .../gc/default/default.c
    #11 gc_sweep .../gc/default/default.c
    #12 gc_start .../gc/default/default.c

“So fiber_switch released the Fiber and then read it?” I asked.

Holmes shook his head. “Look more closely. The garbage collector freed it. fiber_switch merely continued to believe it was alive.”

“Then how could Ruby collect a Fiber while native code was still using its internal pointer?” I asked.

Chapter III: The Two Identities of a Fiber

Holmes drew two names for the same suspect on the blackboard:

Ruby object:     Fiber
native state:    rb_fiber_t *

“At Ruby level,” he said, “the garbage collector keeps an object alive while Ruby can reach it. Inside CRuby, native code often works with the object’s underlying structure.”

“And the structure belongs to the object,” I replied. “Therefore the pointer is valid while the object is alive.”

“Correct. Now reverse the statement.”

I hesitated. “If C still holds the pointer, the object must be alive?”

“That is the assumption we must test. Can Ruby’s garbage collector see an ordinary C local containing rb_fiber_t * as a reference to the owning Ruby object?”

“It cannot,” Holmes said. “A raw native pointer is useful to C, but it is not automatically a Ruby-level reference. If all visible references to the Fiber disappear, the garbage collector may reclaim the object and its native state even though a C stack frame still remembers the address.”

“Then every use of such a pointer is dangerous,” I said.

“Only if garbage collection can run between obtaining it and using it. Let us find that interval.”

Chapter IV: The Function Which Paused Mid-Sentence

The relevant code appeared almost too ordinary:

fiber_store(fiber, th);

if (FIBER_TERMINATED_P(fiber)) {
    fiber_stack_release(fiber);
}

“The pointer is used immediately after fiber_store returns,” I said. “Where could collection intervene?”

“You are reading it as an ordinary function call,” Holmes replied. “What does fiber_store actually do?”

“It switches to another Fiber.”

“And when does this C frame continue?”

“Not until execution returns to it,” I said. “Before that happens, another Fiber may execute arbitrary Ruby code, remove references, and invoke the garbage collector.”

“Then two adjacent lines of C can be separated by an entire episode of Ruby execution,” Holmes replied.

“The function has paused midway through a sentence,” I said, “and resumes using a noun which may no longer exist.”

“Precisely. Source proximity is not lifetime continuity.”

Chapter V: Reconstructing the Disappearance

We no longer needed the whole HTTP workload. Holmes proposed that we recreate only the ordering which made the pointer stale.

“We need a fiber_switch frame suspended while it still holds its target as a raw pointer,” he said. “Then Ruby code must remove the last reference to that target and force collection before the frame resumes.”

The reduced pure-Ruby test used nested Fiber#resume, Fiber.yield, and GC.start. In outline, it was:

parent = child1 = child2 = nil

parent = Fiber.new do
  child1 = Fiber.new do
    # 4. child1 resumes parent:
    parent.resume
    # 7. child1 terminates after parent yields back to it:
  end

  child2 = Fiber.new do
    # 10. GC can now collect the terminated child1:
    GC.start
    # 11. ASan reports as fiber_switch reads stale child1:
    parent.resume
  end

  # 2. Return to root after creating both children:
  Fiber.yield

  # 5. Drop the shared Ruby reference to child1:
  child1 = nil
  # 6. Yield back to child1 while C holds its raw pointer:
  Fiber.yield
end

# 1. Parent creates child1 and child2:
parent.resume

child = child1
# 3. Root keeps only this temporary reference to child1:
child1 = nil
child.resume
# 8. child1 has terminated; drop the temporary reference:
child = nil

child = child2
# 9. Root keeps only this temporary reference to child2:
child2 = nil
child.resume

“The first child is the target Fiber still held by the parent’s suspended fiber_switch call,” I said. “After it terminates and the root drops its temporary reference, the raw pointer in that C frame is all that remains.”

“And the garbage collector does not treat that raw pointer as a reference,” Holmes replied.

I looked back at the reduced test. “Then none of this requires HTTP.”

“Nor sockets, nor an event loop,” said Holmes.

“Then Async did not cause the defect. Its task cleanup merely arranged these Fiber transitions often enough to expose it.”

“The network reproduction shows the practical impact,” I said. “The pure-Ruby reproduction proves which Ruby semantics are sufficient to produce the invalid lifetime.”

Holmes nodded. “The witness and the reconstruction serve different purposes.”

Chapter VI: The Suspicious Earlier Change

The crash appeared in Ruby 3.4.10 after the fix for Bug #21955. That change broadened the handling of Fibers which terminate during a switch. A post-switch check previously limited to Fiber#resume now applied correctly to Fiber.yield and Fiber#transfer as well.

“There is our regression,” I said. “Restore the old condition and the invalid read disappears.”

“Does the old condition make the pointer live?” Holmes asked.

“No. It avoids reading it on those paths.”

“And why was the condition broadened?”

“Because terminated targets require handling on those paths too.”

“Then restoring the old condition would only hide the lifetime defect,” I said. “It would also abandon the intended handling of terminated targets.”

“Exactly,” Holmes replied. “The earlier change makes the stale-pointer read observable, but the underlying rule is more fundamental: any path using fiber after a suspending switch must keep its owner alive.”

Chapter VII: Guarding What C Still Needs

The final Ruby fix was small enough to fit beneath Holmes’s earlier diagram:

// Retain the owning Fiber object as a Ruby VALUE:
VALUE fiber_value = fiber->cont.self;

// Switch away; this C frame is suspended while Ruby runs elsewhere, including GC:
fiber_store(fiber, th);

// The raw fiber pointer is still used after we switch back:
if (FIBER_TERMINATED_P(fiber)) {
    fiber_stack_release(fiber);
}

// Ensure fiber_value remains live past the final use of fiber:
RB_GC_GUARD(fiber_value);

“We copy the owning Ruby object into fiber_value,” I said. “But why place RB_GC_GUARD after the switch?”

“Because the guard marks the end of the lifetime we require,” Holmes replied. “It prevents the compiler from treating fiber_value as dead before that point, so Ruby’s garbage collector can continue to see the owning Fiber object as live.”

“The raw pointer itself does not become a garbage collector reference,” I said. “We keep its owner alive instead.”

“And preserving the owner preserves its rb_fiber_t across the suspended interval,” Holmes replied.

The regression test preserved the nested resume/yield ordering and forced collection while the C frame was paused. A stock Ruby 3.4.10 ASan build reproduced the use-after-free; the guarded build completed without it. The fix was also backported to Ruby 3.4.

Epilogue: Liveness Across Suspension

“I had treated the C function as one uninterrupted act,” I said as Holmes closed the debugger. “The code encouraged me to do so: acquire a pointer, call a function, inspect the pointer.”

“And yet that call yielded control to Ruby,” he replied. “Whenever native code crosses a boundary which can execute arbitrary Ruby, its assumptions about object lifetime must survive the same journey.”

“Then the lesson extends beyond Fibers,” I said. “A callback or scheduler hand-off can also place substantial execution between adjacent native instructions.”

“Indeed. Any raw pointer used after such a boundary needs an owner whose lifetime is explicit.”

“Then the Fiber did not vanish while we were looking away,” I concluded. “It vanished while our own frame was asleep.”

“Exactly, Watson.”

End of Account

Dr. Claude Watson
221B Baker Street
August 2026