class

HTTP2::Connection

Inherits Reference < Object

Owns one HTTP/2 transport, its ordered writer, reader, and stream registry.

Constants

DrainQuietPeriod = 10.milliseconds
MAX_BATCH = 64

Upper bound on how many WriteCommands the writer fiber stages into the transport buffer before forcing a flush (#writer_loop, #flush_batch), even if more immediately-available work remains. Keeps a single flush's completion latency bounded under sustained load instead of growing unboundedly with queue depth.

Preface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".to_slice

Constructors

connect_prior_knowledge(host : String, port : Int = 80, configuration : Configuration = Configuration.new, *, connect_timeout : Time::Span | Nil = nil, read_timeout : Time::Span | Nil = nil, write_timeout : Time::Span | Nil = nil) : self

Opens a cleartext connection using HTTP/2 prior knowledge.

read_timeout and write_timeout default to nil (no transport deadlines). Against untrusted or unreliable peers, set them and/or enable Configuration#keepalive_interval; otherwise a silent or write-stalled peer can hold blocked callers indefinitely. HTTP2::Client sets write_timeout by default and bounds the handshake with a per-wait deadline instead of read_timeout; it enables keepalive by default to detect a silent peer once active.

Source
connect_tls(host : String, port : Int = 443, *, server_name : String = host, context : OpenSSL::SSL::Context::Client = default_tls_context, configuration : Configuration = Configuration.new, connect_timeout : Time::Span | Nil = nil, read_timeout : Time::Span | Nil = nil, write_timeout : Time::Span | Nil = nil, handshake_read_timeout : Time::Span | Nil = nil) : self

Opens a verified TLS connection that requires ALPN to select h2.

read_timeout and write_timeout default to nil (no transport deadlines). Against untrusted or unreliable peers, set them and/or enable Configuration#keepalive_interval; otherwise a silent or write-stalled peer can hold blocked callers indefinitely. handshake_read_timeout bounds only the TLS handshake itself (see start_tls) without leaving a persistent read_timeout armed afterward. HTTP2::Client sets write_timeout by default and bounds the TLS and HTTP/2 handshakes with per-wait deadlines (handshake_read_timeout and wait_until_active, respectively) instead of a persistent read_timeout; it enables keepalive by default to detect a silent peer once active.

context, whether supplied or defaulted, is configured for ALPN "h2" IN PLACE, unconditionally, on every call — see start_tls's doc comment for the full contract, why no private copy is made instead, and the caller-visible consequence of configuring in place.

Source
new(transport : IO, configuration : Configuration = Configuration.new)
Source
start(transport : IO, configuration : Configuration = Configuration.new) : self

Creates and starts a connection over a caller-supplied duplex IO.

TLS callers: prefer connect_tls/start_tls, or HTTP2::Client. If transport is a caller-built OpenSSL::SSL::Socket, this connection's tls_raw_transport stays nil forever — only start_tls can set it (its setter is protected, so there is no way for a caller to supply it here), and Crystal's OpenSSL::SSL::Socket exposes no accessor for the raw IO behind its BIO, so the library has no way to discover it after the fact either. That leaves #close/#terminate exposed to the unbounded SSL_shutdown close hang described on #close_transport's doc comment: with no raw socket to force-close instead, shutdown falls back to the TLS wrapper's own close, which can block indefinitely against a stalled peer if transport has no write_timeout set. connect_tls, start_tls, and HTTP2::Client are unaffected — they dial the raw socket themselves and record it. A caller who must use this constructor with their own TLS socket should set a write_timeout on it directly to bound that close.

Source
start_tls(transport : IO, server_name : String, *, context : OpenSSL::SSL::Context::Client = default_tls_context, configuration : Configuration = Configuration.new, handshake_read_timeout : Time::Span | Nil = nil) : self

Wraps a supplied transport in verified TLS and starts HTTP/2.

handshake_read_timeout, when given, bounds each individual read during the TLS handshake (per read, like read_timeout elsewhere — not a cumulative deadline for the whole handshake) by setting it as transport's read_timeout for the duration of the handshake only; the transport's previous read_timeout (nil, or whatever a caller set directly) is reinstated once the handshake and ALPN check are done, before HTTP/2 starts — so it never lingers as a persistent transport-level deadline afterward, and a caller who also set a persistent read_timeout directly on transport gets it back unchanged. Silently ignored if transport doesn't support read_timeout= (its static type is the untyped IO). connect_prior_knowledge has no equivalent parameter because Connection#start performs no synchronous read of its own — the cleartext dial path never blocks on a read before wait_until_active is already covering the wait.

context is configured for ALPN "h2" IN PLACE, unconditionally, on every call — never on a private copy. OpenSSL::SSL::Context::Client wraps a bare SSL_CTX* behind @handle, and Crystal's default #dup only shallow-copies instance variables, so a dup'd context would share that SAME @handle with the original (verified while investigating this: both report an identical to_unsafe pointer address). Mutating the "copy" would mutate the original's underlying C state too, buying no isolation — and BOTH Crystal objects would independently call LibSSL.ssl_ctx_free(@handle) from their own #finalize, freeing the same handle twice. SSL_CTX also has no deep-copy operation in OpenSSL itself, so a safe copy is not available by any route; ALPN "h2" is mandatory for this library regardless, so the mutation cannot be avoided either way. #alpn_protocol= internally frees its previous protos buffer and mem-dups the new one (SSL_CTX_set_alpn_protos), so setting it again on every dial is cheap and safe, not merely tolerated.

Setting it unconditionally, every dial, is deliberate, not an oversight: it is SELF-HEALING against anything else that changes context.alpn_protocol between dials — the next dial through this library re-asserts "h2" regardless of what it finds. The symmetric, caller-visible consequence: do not share one context between an HTTP2::Client/connect_tls/start_tls caller and a DIFFERENT consumer that needs a different, stable ALPN protocol on it — every dial through this library overwrites alpn_protocol back to "h2" unconditionally, even if that other consumer set it to something else in between.

Source

Class methods

default_tls_context

Builds the TLS client context used when a caller does not supply one: connect_tls's and start_tls's default context: argument, and HTTP2::Client's default tls_context, all call this, so every internally created context stays in lockstep.

Disables TLS 1.0 and TLS 1.1 explicitly — RFC 9113 §9.2: "deployments of HTTP/2 ... MUST NOT use TLS 1.1 or lower" — rather than relying on OpenSSL::SSL::Context's own constructor already disabling both by default (true since Crystal 0.35.0, for every context, including a caller-supplied one — see the task report for the upstream commit). That stdlib default is not part of this method's documented contract and not something a future Crystal or alternate OpenSSL binding is obligated to preserve, so this library asserts its own RFC floor instead of depending on it silently.

A context a CALLER supplies directly to connect_tls/start_tls/ HTTP2::Client.new is never passed through this method and is never modified this way — only the internally created default is.

Source

Instance methods

active?
Source
active_stream_count
Source
close

Idempotently terminates the runtime and wakes every waiter.

Source
close_if_idle

Atomically claims and closes an active connection only if it has no registered streams or pending request reservations.

:nodoc:

Source
closed?
Source
configuration
Source
diagnostics

A bounded stream of structured connection events. Producers never block; inspect #dropped_diagnostic_count to detect a slow consumer.

Diagnostic emission is gated off the frame path and only turns on the first time this accessor is called (@diagnostics_enabled) -- frames, errors, and lifecycle events observed before that first call are deliberately never captured. Call this (even without receiving from the returned channel right away) before driving any traffic whose diagnostics you need to see.

Source
draining?
Source
dropped_diagnostic_count
Source
effective_local_settings_state
Source
graceful_close(timeout : Time::Span = @configuration.drain_timeout) : Nil

Sends GOAWAY(NO_ERROR), refuses new streams, and lets established streams finish until the deadline. The deadline is shortened, never extended, by concurrent graceful-close or peer-GOAWAY requests.

Source
last_goaway
Source
last_sent_goaway
Source
local_settings
Source
local_settings_state
Source
materialize_request_stream(reservation : RequestSlotReservation) : Stream

Converts a request-slot reservation into an idle client stream without an admission-accounting gap.

:nodoc:

Source
new_stream
Source
peer_settings
Source
peer_settings_state
Source
pending_settings_count
Source
ping(payload : Bytes = Bytes.new(8, 0_u8), timeout : Time::Span | Nil = nil) : Nil

Sends a PING and waits for the matching acknowledgement. Concurrent PINGs with identical payloads are matched in submission order.

Source
ping(payload : String, timeout : Time::Span | Nil = nil) : Nil
Source
receive_window
Source
release_receive_credit(stream_id : UInt32, amount : Int32) : Nil

Returns receive-window credit after application bytes leave a bounded stream body. Connection credit is always restored; stream credit is omitted once the peer has ended that stream.

Credit always accumulates, but the writer is only woken once pending credit reaches a half-window watermark (connection or stream scope) — coalescing what would otherwise be a WINDOW_UPDATE pair on every body read. A writer woken for any OTHER reason still flushes all pending credit unconditionally (see take_pending_window_updates); this is by design, not a bug — the watermark only gates the wake, not the send.

:nodoc:

Source
release_request_slot(reservation : RequestSlotReservation) : Nil

Idempotently releases a pending request-slot reservation.

:nodoc:

Source
request_capacity

Returns an authoritative request-admission snapshot.

:nodoc:

Source
retained_closed_stream_count
Source
send_data(stream_id : UInt32, data : Bytes, *, end_stream : Bool = false) : Nil

Sends application data through the flow-control scheduler.

Under the default single-threaded runtime, data is sliced, not copied: submit_data (via #command.wait) blocks until each chunk's frame has been memcpy'd into the transport buffer and its batch has either flushed or failed (see #flush_batch) before returning, so by the time this call returns, nothing internal to the connection still reads from data. See #stage_scheduled_data's comment for the invariants that guarantee this.

Under -Dpreview_mt, #owned_for_write takes a private copy of each chunk instead: #terminate is reachable from fibers not pinned to the writer's thread (#close, and the drain-monitor, keepalive, and settings-timer loops, all plain ::spawn unlike #spawn_transport_fiber's pinned reader/writer fibers), and it can unblock this call's #command.wait on a different OS thread while the writer thread is still copying the chunk into the transport buffer.

Either way, the caller must leave data unmodified until this call returns; it is never retained afterward.

Source
send_data(stream_id : UInt32, source : IO, *, end_stream : Bool = true) : Nil

Streams DATA from the source's current position without rewinding it.

Reads into two reusable buffers instead of allocating one per chunk. This relies on #send_data(Bytes) being synchronous: it blocks until the chunk it was given has been handed to the transport before returning, so a buffer is never refilled while a prior call might still be reading from it (default single-threaded runtime), or is refilled only after #owned_for_write has already taken a private copy of the in-flight chunk (-Dpreview_mt; see #send_data(Bytes)'s doc comment). current and following swap roles each iteration; the loop always reads into the buffer that was NOT just handed to #send_data.

Source
send_data_frame(frame : Frame::Data) : Nil

Preserves explicit DATA padding while still applying flow control. A padded frame is atomic because splitting it would change its wire shape.

:nodoc:

Source
send_headers(stream_id : UInt32, fields : Enumerable(Tuple(String, String)), *, end_stream : Bool = false) : Nil

Encodes ordered name/value pairs with the default field policy.

Source
send_headers(stream_id : UInt32, fields : Enumerable(HeaderField), *, end_stream : Bool = false) : Nil

HPACK-encodes one ordered field section on the writer fiber and sends its complete HEADERS/CONTINUATION sequence atomically.

Source
send_settings(entries : Enumerable(Frame::Settings::Setting)) : Nil

Sends a SETTINGS update and tracks its ordered acknowledgement.

Source
send_window
Source
start

Starts the writer, emits the complete client preface atomically, then starts the continuous reader.

Source
state
Source
stream?(id : UInt32) : Stream | Nil
Source
submit_headers(stream_id : UInt32, fields : Enumerable(HeaderField), *, end_stream : Bool = false) : WriteCommand

Enqueues one ordered field section onto the writer's FIFO @write_queue without waiting for it to reach the transport (the HPACK encoding itself is deferred further still — to the writer fiber, once it dequeues this command — so this method only ever does admission-order bookkeeping). Returns the WriteCommand so a caller that holds an external ordering lock across multiple same-connection submitters (see Client#open_request_stream's opening_mutex) can release that lock once this command is admitted to @write_queue in the correct position, instead of holding it for the blocking wait too. #send_headers is the fused submit+wait convenience most callers want.

Takes its own defensive copy of fields (see #submit_owned_headers) because encoding happens later, on the writer fiber — a caller that mutated its own Enumerable after this method returns must not be able to change what eventually gets encoded.

:nodoc:

Source
subscribe_pool_state

Registers an independent, nonblocking pool-state listener.

:nodoc:

Source
terminal_error
Source
try_reserve_request_slot

Atomically admits one future client request without allocating a stream ID or sending bytes. Ordinary lack of capacity returns nil.

:nodoc:

Source
wait_closed(timeout : Time::Span | Nil = nil) : Nil
Source
wait_for_stream_slot(timeout : Time::Span, cancellation : Channel(Nil) | Nil = nil) : Nil

Waits for a peer-imposed concurrent-stream slot to possibly have freed up, for cancellation to fire, or for timeout to elapse — whichever happens first. Returns in all three cases without raising: the caller is expected to retry the #send_headers call that raised ConcurrentStreamLimitError (and to check its own cancellation/deadline), so a stale or spurious wakeup here is harmless — it costs at most one extra retry. This remains available to raw Connection users; HTTP2::Client now performs pool-wide request-slot acquisition instead.

:nodoc:

Source
wait_until_active(timeout : Time::Span | Nil = nil) : Nil
Source
write_batch(frames : Array(Frames)) : Nil

Writes a frame batch without allowing another command to interleave. Validates every frame in array order and raises on the first one that's invalid to send this way; a batch containing more than one kind of invalid frame raises whichever category's error the first invalid frame belongs to, not a fixed category priority.

Source
write_frame(frame : Frames) : Nil

Writes one frame through the connection's sole writer fiber.

Source

Nested types