Skip to content

Non-consuming readability checks (readable?/viable?) share the :readable guard with consuming reads — async-http's H2 pool viability peek trips a false positive under concurrent multiplexing - problem with ruby_llm #4

Description

@thomaswitt

@ioquatix

Summary (a classification question, not a corruption report)

Under an Async reactor (Falcon), a single shared async-http client multiplexes concurrent
request fibers over one HTTP/2 connection: the pooled H2 connection resource's concurrency
is the connection's maximum_concurrent_streams, while the pool controller's own
concurrency remains the acquire/create guard. During pool acquisition,
Async::HTTP::Protocol::HTTP2::Connection#viable? calls @stream&.readable?
IO::Stream::Buffered#readable?, which checks buffered state and then calls the wrapped
socket's readable?; for the socket shim this is a non-consuming recv_nonblock(1, MSG_PEEK) (io-stream .../shim/readable.rb) that neither consumes bytes nor mutates the read
buffer. IO::Stream::Readable classifies readable? under the same :readable guard as the
consuming reads (io-stream .../readable.rb: ASYNC_SAFE = {read: :readable, readable?: :readable, ...}), and the monitor raises when a fiber calls a :readable-guarded method while
another fiber holds it. So when concurrent request fibers reuse the same H2 connection, a
pool-acquire viable? check can overlap with the connection's background reader holding the
:readable guard, and async-safe raises Concurrent access detected (guard: readable).

Evidence that this specific peek is safe (NOT a claim of universal safety)

With async-safe disabled, 16 concurrent fibers issuing requests to the same HTTP/2 host through
ONE shared persistent client return 16/16 correct per-request responses, zero wrong-stream
data, zero errors. This indicates the multiplexed-H2 pool-acquire path is fiber-safe for ordinary
buffered request/response. (I am explicitly NOT vouching for response-streaming callbacks, shared
request-body IO, GOAWAY/retry, connection eviction mid-flight, or cross-thread sharing.)

How users end up here: ruby_llm 1.16.0
added config.faraday_adapter and documents :async_http for async servers (Falcon), so any
Rails app combining that with async-safe in dev/test hits this on its first concurrent LLM
calls — that's how we found it.

Workaround we're running (narrow, proven)

Reclassifying only readable? to true — the same "internally safe, skip tracking" idiom
IO::Stream::Writable already uses for write/puts/flush — resolves the false positive
while keeping every consuming read guarded:

if IO::Stream::Readable::ASYNC_SAFE[:readable?] == :readable
  module IO::Stream::Readable
    reclassified = ASYNC_SAFE.merge(readable?: true).freeze
    remove_const(:ASYNC_SAFE)
    const_set(:ASYNC_SAFE, reclassified)
  end
end

Verified with monitoring active: (a) 16/16 concurrent LLM requests over one shared multiplexed
client succeed with zero violations; (b) a genuine race — two fibers concurrently inside a
consuming read on the same IO::Stream::Bufferedstill raises ViolationError. If a
guard split (or readable?: true upstream) is acceptable, we'd happily drop this local patch.

Question

Is flagging this intended (nudging users toward per-fiber clients), or should non-consuming
readability checks
(readable?, and hence a pool's viable?) be classified under a separate
guard from consuming reads (read/read_partial/…) so a connection-pool viability peek on a
legitimately-multiplexed H2 connection does not trip the :readable guard? If a split guard
isn't desirable, is there a recommended way to run async-http-faraday's default shared client
under a reactor with async-safe enabled in a test suite?

Environment

  • async-safe 0.5.1, io-stream 0.13.1, async-http 0.95.1, async-http-faraday 0.22.2,
    async-pool 0.11.2, async 2.41.0, falcon 0.55.5, ruby 3.4.8

Minimal reproduction

require 'faraday'; require 'async'; require 'async/barrier'
require 'async/http/faraday'; require 'async/safe'
Async::Safe.enable!
conn = Faraday.new { |f| f.adapter :async_http }   # default PerThreadPersistentClients
Sync do
  b = Async::Barrier.new
  8.times { b.async { conn.get('https://<any HTTP/2 host>/') rescue warn($!.class) } }
  b.wait
end
# => most fibers raise Async::Safe::ViolationError: Concurrent access detected (guard: readable)
#    on IO::Stream::Buffered#readable?; with Async::Safe disabled, all succeed correctly.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions