Skip to content

Fix silent callback loss on JRuby (parser hangs after JVM GC) - #39

Merged
bryanp merged 1 commit into
bryanp:mainfrom
sferik:retain-callback-procs
Jul 19, 2026
Merged

Fix silent callback loss on JRuby (parser hangs after JVM GC)#39
bryanp merged 1 commit into
bryanp:mainfrom
sferik:retain-callback-procs

Conversation

@sferik

@sferik sferik commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

On JRuby, LLHttp::Parser intermittently stops invoking delegate callbacks after a JVM garbage-collection cycle: llhttp_execute returns success, but no callbacks fire and the parser reports no progress. Downstream, this manifests as consumers blocking forever waiting for parse results on data they have already received. This turned out to be the root cause of an intermittent hang in httprb/http’s test suite on JRuby.

Parser#initialize assigns each callback proc directly into the LLHttp::Callbacks FFI struct and keeps no other reference to it:

@callbacks[callback] = method(callback).to_proc

MRI’s FFI retains a Ruby reference for procs assigned into FFI::Struct callback fields, but JRuby’s FFI (JNR) does not. On JRuby the procs are therefore unreachable as soon as initialize returns; when the JVM collects them, their native trampolines are reclaimed while the llhttp settings struct still points at them. Subsequent llhttp_execute calls complete without error and without invoking any callbacks.

Here is a minimal reproduction:

require "llhttp"

class Delegate < LLHttp::Delegate
  attr_reader :complete

  def on_message_complete
    @complete = true
  end
end

RESPONSE = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"

5_000.times do |i|
  delegate = Delegate.new
  parser = LLHttp::Parser.new(delegate, type: :response)

  50.times { LLHttp::Parser.new(Delegate.new, type: :response) } # churn
  java.lang.System.gc if (i % 5).zero?

  parser << RESPONSE

  next if delegate.complete

  puts "iteration #{i}: llhttp_execute succeeded but no callbacks were invoked"
  exit 1
end

puts "no callback loss in 5000 iterations"

On jruby 10.1.0.0 with llhttp-ffi 0.5.1 (macOS arm64) this fails on iteration 0. Without the explicit System.gc it still fails, just less deterministically. In a real HTTP client workload, I measured roughly one silent parse failure per 25–70 parsers under normal GC pressure.

The patch I’m proposing keeps a reference to each assigned proc on the parser instance, tying the trampolines’ lifetime to the parser that owns them. Please let me know if there are any changes you’d like me to make.

JRuby's FFI does not retain Ruby references to procs assigned into
FFI::Struct callback fields. The procs assigned into LLHttp::Callbacks
were referenced nowhere else, so on JRuby they became garbage as soon as
Parser#initialize returned. Once the JVM collected them, their native
trampolines were reclaimed and llhttp_execute would complete
successfully without invoking any callbacks, silently producing an
unparsed result.

Keep an explicit reference to each callback proc on the parser instance
so the trampolines live exactly as long as the parser that owns them.
MRI is unaffected (its FFI already retains struct-assigned procs).
@bryanp

bryanp commented Jul 19, 2026

Copy link
Copy Markdown
Owner

@sferik fix makes sense, thanks for tracking this down! I'll cut a new release soon.

@bryanp
bryanp merged commit 56efa13 into bryanp:main Jul 19, 2026
13 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