Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ jobs:
- run: bundle exec rake rbs:validate
- run: bundle exec rake steep
- run: bundle exec rake test:gates
- run: bundle exec rake gates:serde_boundary
- run: bundle exec rake gates:rbs_surface
- run: bundle exec rake gates:sig_diff
- run: bundle exec rake gates:surface_snapshot
Expand Down
1 change: 1 addition & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ DEFAULT_GATES = %w[
test:gates
gates:gemspec_audit
gates:require_allowlist
gates:serde_boundary
gates:clean_bundle
gates:rbs_surface
gates:sig_diff
Expand Down
20 changes: 20 additions & 0 deletions gems/dexpace-core/lib/dexpace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,26 @@
require_relative "dexpace/redirect/reissue"
require_relative "dexpace/redirect/step"

# Phase 7b: the Server-Sent Events layer, in dependency order. Nothing here depends on phases
# 4c through 6: the namespace file needs only the sentinel type it holds the two instances of;
# the two errors need the error root; the line machine reads a byte source through #getbyte
# (3a's BufferedSource satisfies its interface structurally, and it is deliberately NOT built on
# #read_line_utf8, whose IO-14 grammar keeps a lone CR as content where SSE-2 terminates on it);
# the event value is a Data including Model; the reader sits on the line machine and the value;
# the facade includes phase 2's Closeable, reads a phase-3b response body's source, and requires
# the typed adapter itself; the typed adapter names the two sentinels. The layer requires no
# stdlib feature at all and names no Dexpace::Serde constant, which `gates:serde_boundary` scans
# for (SSE-37).
require_relative "dexpace/sse"
require_relative "dexpace/sse/sentinel"
require_relative "dexpace/sse/limit_exceeded_error"
require_relative "dexpace/sse/stream_state_error"
require_relative "dexpace/sse/line_reader"
require_relative "dexpace/sse/event"
require_relative "dexpace/sse/reader"
require_relative "dexpace/sse/stream"
require_relative "dexpace/sse/typed_stream"

# The dexpace Ruby SDK: an HTTP-client toolkit, not an HTTP client.
#
# This file issues explicit `require_relative`s for the whole tree rather than using an
Expand Down
7 changes: 5 additions & 2 deletions gems/dexpace-core/lib/dexpace/io/typed_reads.rb
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,11 @@ def eof?
#
# Unbounded on purpose (P3-4): the one drain-style read MAX_MATERIALIZED_BYTES does not
# guard, because a line has no count to check and no end but a terminator that may never
# arrive. IO-14 fixes no line length; the caller that reads lines from a hostile stream is
# phase 7's SSE machine, whose own documented cap SSE-11 requires (phase 7b's plan, Task 12).
# arrive. IO-14 fixes no line length, so the bound is the caller's, and this method has no
# caller in this repository: phase 7's SSE machine reads bytes through #getbyte and carries
# its own documented line cap, Dexpace::SSE::MAX_LINE_BYTES, the one SSE-19 sanctions --
# not SSE-11, which caps the retry field -- because this method keeps a lone "\r" as
# content where SSE-2 makes it terminate a line (phase 7b, P7-20 and P7-21).
def read_line_utf8
ensure_typed_reads_initialized
ensure_readable
Expand Down
68 changes: 68 additions & 0 deletions gems/dexpace-core/lib/dexpace/sse.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT

require_relative "sse/sentinel"

module Dexpace
# The Server-Sent Events subsystem, chapter 13: the WHATWG line and field state machine
# (LineReader, Reader), the immutable five-field event value (Event), the resource-owning
# single-pass streaming facade (Stream) and the typed adapter over a caller-supplied mapper
# (TypedStream). It owns no reconnection policy, no last-event-id continuity and no per-API
# sentinel convention (SSE-37, SSE-38); those live in caller-supplied code, and the one
# extension point is Stream#typed's mapper, whose vocabulary is SKIP and DONE.
#
# Nothing here names a Dexpace::Serde constant or requires a serialization feature, and
# `gates:serde_boundary` scans this file and every file under lib/dexpace/sse/ to keep it so
# (SSE-37). The three limits below are the module's, beside the module whose limits they are --
# phase 3a's placement of Dexpace::IO::MAX_MATERIALIZED_BYTES in io.rb, applied unchanged.
module SSE
# SSE-19's line cap, as the DEFAULT: 1 MiB. Chosen, not derived. The reference imposes no
# maximum line size, so this is SSE-19's documented divergence -- the chapter's own sanction
# is "a port MAY add a configurable cap and reject/truncate oversized lines, documenting the
# divergence" -- and the port REJECTS, raising LimitExceededError from the pull on which a
# line crosses the bound, and never truncates (P7-21). Configurable per reader through the
# `max_line_bytes:` keyword on LineReader.new, Reader.new and the three Stream factories; no
# configuration key exists for it, because the caller constructs the reader and is therefore
# the configuration source.
#
# This is the bound phase 3a's line-cap finding names: the one drain-style read
# Dexpace::IO::MAX_MATERIALIZED_BYTES does not guard is #read_line_utf8 (P3-4), and the bound
# belongs in the layer that knows what a line means. It is a different bound at a different
# layer, not a second ceiling under 3a's: the SSE machine reads bytes through
# BufferedSource#getbyte and calls none of the drain methods 3a's ceiling guards, so the two
# never meet on one operation. 1 MiB matches phase 3b's MAX_BUFFERED_ERROR_BODY_BYTES in
# magnitude, deliberately -- one order of magnitude for a reader to remember -- and shares no
# constant with it. #read_line_utf8 itself remains unbounded and, after this phase, has no
# caller in this repository: IO-14 keeps a lone "\r" as content where SSE-2 makes it terminate
# a line, so the SSE machine cannot be built on it (P7-20).
MAX_LINE_BYTES = 1024 * 1024

# SSE-19's event cap, as the DEFAULT: 8 MiB of raw bytes accumulated into one event block,
# reset at every dispatch. Chosen, not derived. Appendix C states the surface SSE-19 leaves
# open as "no maximum line OR EVENT size", and a line cap alone leaves 2^20 one-byte data lines
# unbounded, so the MAY's inverse is taken over the whole of the subject the requirement
# names and no further: there is no cap on the number of events in a stream (P7-21). Eight
# times the line cap, so a legitimate multi-line event carries several large lines; one
# eighth of Dexpace::IO::MAX_MATERIALIZED_BYTES, so it is visibly beneath 3a's ceiling.
# Rejected, never truncated; configurable per reader through `max_event_bytes:`.
MAX_EVENT_BYTES = 8 * 1024 * 1024

# SSE-11's magnitude cap on the `retry` field: 2^31 - 1 milliseconds, design section 10.18's
# substituted constant. Ruby's Integers are arbitrary-precision and cannot overflow, so the
# reference's "maximum representable millisecond magnitude" has no runtime analogue here; the
# port documents this cap and IGNORES a larger value rather than wrapping, which preserves the
# requirement's observable behaviour. Written as the literal because Integer#** types as
# Numeric under the strict Steep target. This is NOT the line cap: SSE-11 is a MUST about the
# retry field's value, SSE-19 a MAY about line length, and the two caps carry two constants
# and two checklist rows because they were confused once already.
MAX_RETRY_MS = 2_147_483_647

# SSE-34: the mapper outcome that drops the event and advances to the next, never surfacing
# it to the consumer. Compared by identity, never by value.
SKIP = Sentinel.send(:new, name: :skip)

# SSE-34: the mapper outcome that ends the iteration cleanly and closes the stream without
# yielding a model for the sentinel event itself. Compared by identity, never by value.
DONE = Sentinel.send(:new, name: :done)
end
end
96 changes: 96 additions & 0 deletions gems/dexpace-core/lib/dexpace/sse/event.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT

require_relative "../model"
require_relative "../error/invalid_argument_error"

module Dexpace
module SSE
# One parsed server-sent event: the five tracked fields of one block (SSE-13) -- `id`,
# `event`, `data`, `comment` and `retry` -- as an immutable value (SSE-20) with structural
# equality, hash and string form over all five (SSE-21).
#
# `data` is the ordered list of raw per-line values exactly as the wire carried them, never
# joined (SSE-8): joining with "\n" is the typed layer's (SSE-33). `event` is the raw field,
# nil when the block sent none and NEVER defaulted to "message" (SSE-10); `id` is the block's
# own and never a carried last-event-id (SSE-16); `retry` is a non-negative millisecond count
# a caller may act on and the SDK never does (SSE-38). A present-but-empty field is "" or [""],
# distinct from an absent nil (SSE-4).
#
# SSE-20's defensive copy is Model.own -- Ractor.make_shareable(list, copy: true), a
# deep-frozen copy -- taken in #initialize, so neither the list the parser accumulated into
# nor a caller's later mutation reaches inside a constructed event; and SSE-20's
# copy-with-changes clause is Dexpace::Model#with, which routes through .build and therefore
# through the same copy, on every supported Ruby. No bespoke #with is written here: Data#with
# shares members and skips an #initialize override on the 3.2 floor (data-modeling/83610619).
# The member is named `retry`, after the wire field: a Ruby keyword is an ordinary method name
# on a receiver, and RBS accepts it (open question 1, closed on every row).
class Event < ::Data.define(:id, :event, :data, :comment, :retry)
include Model

private_class_method :new, :[]

# The validating factory every construction path goes through, absent fields defaulting
# to absent.
#
# @param id [String, nil] the block's own id, verbatim (SSE-9)
# @param event [String, nil] the raw event name; nil when none was sent (SSE-10)
# @param data [Array<String>] the raw data lines in wire order, unjoined (SSE-8)
# @param comment [String, nil] the block's last comment (SSE-6)
# @param retry [Integer, nil] the accepted retry hint in milliseconds (SSE-11)
# @return [Event] frozen, owning a deep-frozen copy of `data`
# @raise [Dexpace::InvalidArgumentError] naming the first field of the wrong shape
def self.build(id: nil, event: nil, data: [], comment: nil, retry: nil)
new(id:, event:, data:, comment:, retry:)
end

# HTTP-4's field-named validation. Each String is copied and frozen (XCUT-15) and the list
# is deep-copied and frozen once, here, so every accessor returns the same frozen reference
# with no per-access wrapper. The bare `super` forwards the (re-assigned) keywords. The
# retry hint is validated AFTER `super`, through its reader: `retry` is a Ruby keyword, so
# `retry = ...` parses as the retry statement and `super(retry: retry)` does not parse at
# all, while an Integer needs no transformation and the reader is the one allocation-free
# way to read the parameter.
def initialize(id:, event:, data:, comment:, retry:)
id = optional_string!("id", id)
event = optional_string!("event", event)
data = Model.own(list!(data))
comment = optional_string!("comment", comment)
super
duration!("retry", self.retry)
end

# SSE-22: true only when all five fields are UNSET -- a nil id, event, comment and retry and
# an empty data list. A present-but-empty field is not unset (SSE-4), so an event whose only
# field is `event: ""` or `data: [""]` is not empty, and a comment-only keep-alive is not
# empty either, because a comment counts as content (SSE-6).
def empty?
id.nil? && event.nil? && data.empty? && comment.nil? && self.retry.nil?
end

private

def optional_string!(name, value)
return nil if value.nil?
return Model.frozen_string(value) if value.is_a?(::String)

raise InvalidArgumentError, "#{name} must be a String or nil, got #{value.class}"
end

def list!(value)
unless value.is_a?(::Array) && value.all?(::String)
raise InvalidArgumentError, "data must be an Array of Strings, got #{value.inspect}"
end

value
end

def duration!(name, value)
return if value.nil? || (value.is_a?(::Integer) && !value.negative?)

raise InvalidArgumentError,
"#{name} must be a non-negative Integer of milliseconds or nil, got #{value.inspect}"
end
end
end
end
50 changes: 50 additions & 0 deletions gems/dexpace-core/lib/dexpace/sse/limit_exceeded_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT

require_relative "../error"
require_relative "../error/invalid_argument_error"

module Dexpace
module SSE
# SSE-19: raised from the pull on which a line, or the bytes accumulated into one event
# block, cross the documented bound -- Dexpace::SSE::MAX_LINE_BYTES or ::MAX_EVENT_BYTES, or
# the per-reader value that replaced it.
#
# The port takes SSE-19's MAY and REJECTS rather than truncates: a truncated data line reaches
# the caller's mapper as a parse failure at a place the caller cannot relate to the cause, and
# design section 10.18's own sanction is "fails or ignores loudly" (P7-21). `kind` is :line or
# :event and `limit` is the value that was crossed, so a caller raising the cap knows which
# keyword to raise. Through the facade it travels SSE-29's mid-stream-failure path: the
# resource is released before it surfaces, and a release failure rides on its suppressed
# trail. Namespaced under SSE rather than flat, because it is meaningless outside this
# subsystem (6c's ProviderError precedent).
class LimitExceededError < ::StandardError
include Dexpace::Error

# The two bounds a reader enforces, and the only values `kind` takes. Private: it is not
# NFR-4 surface (6b's RECOGNIZED_CODES precedent).
KINDS = %i[line event].freeze
private_constant :KINDS

# Which bound was crossed: :line or :event.
attr_reader :kind

# The bound's value in bytes, as the reader that raised was configured.
attr_reader :limit

# @param kind [Symbol] :line or :event
# @param limit [Integer] the bound in bytes
# @raise [Dexpace::InvalidArgumentError] for a kind that is neither bound
def initialize(kind:, limit:)
unless KINDS.include?(kind)
raise InvalidArgumentError, "kind must be :line or :event, got #{kind.inspect}"
end

@kind = kind
@limit = limit
super("an SSE #{kind} exceeded its #{limit}-byte limit and was rejected, not " \
"truncated (SSE-19)")
end
end
end
end
111 changes: 111 additions & 0 deletions gems/dexpace-core/lib/dexpace/sse/line_reader.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT

require_relative "../error/invalid_argument_error"
require_relative "../sse"
require_relative "limit_exceeded_error"

module Dexpace
module SSE
# SSE-2's line machine: LF, CR and CRLF all terminate, CRLF as ONE terminator, terminators
# stripped, a lone CR terminating by itself; a final line without a terminator comes back as
# content (SSE-14); nil once the source is exhausted before any byte.
#
# Built over a byte source's #getbyte and NOT over phase 3a's #read_line_utf8, because the
# two grammars disagree on the one clause that matters: IO-14 requires a lone "\r" be KEPT AS
# CONTENT and SSE-2 requires it TERMINATE a line (P7-20). Post-processing 3a's result would
# not do either: a conforming server that terminates with lone CRs sends no "\n" at all, so
# #read_line_utf8 would buffer the whole stream before returning anything -- the read-ahead
# SSE-39 forbids and the unbounded allocation the line cap exists to prevent.
#
# The one item of state beyond the source is a one-byte pushback: after a CR the machine reads
# one further byte to tell CR from CRLF, and holds it for the next line when it is not LF.
# That gives the grammar an inherent property WHATWG shares: a line terminated by a lone CR
# cannot be handed back until one further byte arrives, because nothing else distinguishes
# CR from the first half of CRLF. LF- and CRLF-terminated streams are unaffected, since the
# byte the machine needs is the terminator that has already arrived.
#
# SSE-19's cap is checked as the accumulator grows, before each append, so an over-long line
# is rejected the moment it crosses the bound rather than after it is materialised; the
# accumulator is one BINARY buffer reused across lines, which keeps the machine
# allocation-flat on a long stream. Lines are BINARY: decoding is the reader's, per field
# value (P7-26). The line reader owns nothing and closes nothing (SSE-17).
class LineReader
LF = 0x0A
CR = 0x0D
private_constant :LF, :CR

# SSE-19's line cap as this reader was configured, in bytes.
attr_reader :max_line_bytes

# @param source [Dexpace::SSE::_ByteSource] anything answering #getbyte -- a
# Dexpace::IO::BufferedSource, or a duck; checked with respond_to?, never is_a?
# @param max_line_bytes [Integer] SSE-19's cap for this reader; MAX_LINE_BYTES by default
# @raise [Dexpace::InvalidArgumentError] for a source without #getbyte or a cap that is not a
# positive Integer
def initialize(source, max_line_bytes: MAX_LINE_BYTES)
unless source.respond_to?(:getbyte)
raise InvalidArgumentError,
"a line reader's source must respond to #getbyte (SSE-2), got #{source.class}"
end
unless max_line_bytes.is_a?(::Integer) && max_line_bytes.positive?
raise InvalidArgumentError,
"max_line_bytes must be a positive Integer, got #{max_line_bytes.inspect}"
end

@source = source
@max_line_bytes = max_line_bytes
@buffer = ::String.new(capacity: 4096, encoding: ::Encoding::BINARY)
@pushback = nil
end

# The next line's content as a fresh frozen BINARY String with its terminator stripped, or
# nil when the source is exhausted before any byte of it arrived.
#
# @return [String, nil]
# @raise [Dexpace::SSE::LimitExceededError] with `kind: :line` from the pull on which the
# line crosses `max_line_bytes`
def next_line
buffer = @buffer.clear
byte = take_byte
return nil if byte.nil?

loop do
break if terminator?(byte)
raise LimitExceededError.new(kind: :line, limit: @max_line_bytes) if
buffer.bytesize >= @max_line_bytes

buffer << byte
following = take_byte
break if following.nil? # SSE-14: exhaustion ends an unterminated line as content

byte = following
end
buffer.dup.freeze
end

private

# The pushback first, then the source.
def take_byte
held = @pushback
return @source.getbyte if held.nil?

@pushback = nil
held
end

# Whether `byte` ends the line. LF does. CR does too, after one further byte decides
# whether it was CRLF: that byte is consumed when it is LF, held back for the next line
# otherwise, and a CR at the very end of the stream terminates on its own.
def terminator?(byte)
return true if byte == LF
return false unless byte == CR

following = @source.getbyte
@pushback = following unless following.nil? || following == LF
true
end
end
end
end
Loading
Loading