Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
4bec966
feat: the synchronous net-http transport and the conformance gem (pha…
Wahbeh-Mohammad Sep 20, 2026
ab034f1
fix: bounded Content-Length regexps, the swap pin and GC state (round 1)
Wahbeh-Mohammad Sep 20, 2026
ff825f0
fix: a pump built over a cancelled token closes without a producer
Wahbeh-Mohammad Sep 20, 2026
0fc263e
fix: the warm-up client takes an explicit nil proxy, never :ENV
Wahbeh-Mohammad Sep 20, 2026
875c9b6
docs: state the status range the response mapper is total over
Wahbeh-Mohammad Sep 20, 2026
bb909ba
fix: a Content-Length beside a chunked encoding is the -1 sentinel
Wahbeh-Mohammad Sep 20, 2026
d63a1f6
test: phase 8a suites and doubles for the net-http transport and the …
Wahbeh-Mohammad Sep 20, 2026
e0c1a4b
test: make three surviving mutations red and guard the round-1 fixes
Wahbeh-Mohammad Sep 20, 2026
a31e1db
test: say which proxy-route test sets the process-wide configuration
Wahbeh-Mohammad Sep 20, 2026
a723d55
test: hermetic proxy keys and the pump built over a cancelled token
Wahbeh-Mohammad Sep 20, 2026
e21b4e4
test: keep the adapter's token check discriminable from the pump's
Wahbeh-Mohammad Sep 20, 2026
54136ca
test: every raw fixture client takes an explicit nil proxy (R2-1)
Wahbeh-Mohammad Sep 20, 2026
ed71b2b
test: say which half of the proxy hermeticity the module covers
Wahbeh-Mohammad Sep 20, 2026
a36a9d2
test: guard the chunked length, the closed-pump read and the handler …
Wahbeh-Mohammad Sep 20, 2026
705d864
test: un-guard the generator slice's codec half now that 7a is on the…
Wahbeh-Mohammad Sep 21, 2026
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
3 changes: 3 additions & 0 deletions Steepfile
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,8 @@ end
target :conformance do
check "gems/dexpace-conformance/lib"
signature "gems/dexpace-conformance/sig", "gems/dexpace-core/sig"
# rbs's own stdlib signature sets for the two features this gem's lib/ requires beyond core's
# allowlist: `socket` for the wire fixture (P8-14) and `tempfile` for TRANSPORT-28's file body.
library "socket", "tempfile"
configure_code_diagnostics(D::Ruby.default)
end
32 changes: 29 additions & 3 deletions gems/dexpace-conformance/lib/dexpace/conformance.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,38 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT

# The whole of dexpace-core: this gem declares it and reaches every Dexpace:: constant its
# assertions name through the one entry point, exactly as a consumer of the suite would.
require "dexpace"
require_relative "conformance/version"
# The assertion protocol's five value types (design §9.3, 8a's R7), in dependency-free order.
require_relative "conformance/failure"
require_relative "conformance/vacuous"
require_relative "conformance/assertion"
require_relative "conformance/result"
require_relative "conformance/report"
# The TCPServer fixture and its named scripts (design §9.3; `socket` is permitted to this gem
# alone by the require allowlist's scoped denial, P8-14).
require_relative "conformance/scripts"
require_relative "conformance/wire_server"
# The case an assertion receives, the borrowed pair, and the runner (8a's R16 suite contract).
require_relative "conformance/borrowed_pair"
require_relative "conformance/transport_case"
require_relative "conformance/transport_suite"
# The Minitest driver, and deliberately NOT the RSpec one: an RSpec-only consumer requires
# "dexpace/conformance/rspec_driver" itself, so a Minitest-only consumer never loads a file naming
# the other framework (design §9.3). Neither requires its framework.
require_relative "conformance/minitest_driver"
# The two observability doubles phases 5c and 5b assigned to this gem (OBS-21, OBS-25).
require_relative "conformance/recording_span"
require_relative "conformance/allocations"

module Dexpace
# The conformance suite every adapter is proven against. Phase 0 ships the namespace and
# VERSION only; the assertion objects and their Minitest and RSpec drivers land in phase 8a,
# and phase 9 adds the remaining suites.
# The conformance suite every adapter is proven against (design §9.3): the assertion protocol
# phase 0 postponed to phase 8a -- Failure, Vacuous, Assertion, Result and Report -- with the
# transport suite, its TCPServer fixture and the two thin drivers landing beside them; phase 9
# adds the remaining suites. Declares dexpace-core and nothing else, by design: neither driver
# requires its framework, so Minitest is never a runtime constraint on a consumer.
module Conformance
end
end
94 changes: 94 additions & 0 deletions gems/dexpace-conformance/lib/dexpace/conformance/allocations.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT

require "dexpace/error/invalid_argument_error"

module Dexpace
module Conformance
# OBS-25's "Selecting a no-op path MUST NOT allocate per call", measured the one way that is
# insensitive to the caller (5b's R8): the block is driven `iterations` and then
# `2 * iterations` times under GC.disable, and the per-call figure is the difference of the two
# deltas over `iterations` -- so a fixed cost (a method cache, an inline cache warming) cancels
# and only a per-iteration cost survives. What makes it insensitive to the CALLER is the
# precondition the block must meet, not the arithmetic: every argument the block passes must be
# one that cannot allocate -- a frozen constant, a Symbol, an Integer, nil -- because an inline
# String or Hash literal at the call site allocates per iteration whether or not the callee
# does. The file's `frozen_string_literal` is a rule of this repository, not this measurement's
# precondition.
#
# The figure returned is the one two consecutive measurements AGREE on -- core's own
# `AllocationDelta` shape, which this copies rather than the single loop 8a's plan sketched.
# On the 3.2.11 floor a one-time interpreter cost of 7 or 28 objects can land inside a measured
# block after the warm-up, once per process, and a single measurement came back NEGATIVE about
# one whole-file run in fifteen; an integer division of that is -1 and an exact zero assertion
# fails on it. A one-time cost cannot appear in two consecutive measurements, while a real
# per-call cost is exactly what every clean measurement returns, so the measurement repeats
# until two in a row agree (at most ATTEMPTS, then the last figure is returned and the
# assertion reports it) -- which keeps the assertion exact rather than clamping a negative
# figure or widening the delta, either of which would also hide a real fractional cost.
module Allocations
extend self

# How many measurements may disagree before the helper gives up and reports the last.
ATTEMPTS = 5

# The iterations run before the first measurement, so the first call's one-time costs --
# block object creation, method dispatch caches -- do not land in the measured block.
# Measured: `GC.stat(:total_allocated_objects)` around an empty block reports 2 and around
# `{ nil }` 3 or 4 on the four supported interpreters, so the harness's own overhead is
# caller-shaped and the two-loop difference is what removes it.
WARMUP_ITERATIONS = 100
private_constant :WARMUP_ITERATIONS

# Objects allocated per call of the block, to one thousandth: the figure two consecutive
# two-loop measurements agree on. `iterations:` is REQUIRED (8a's open question 5): OBS-25's
# "MUST NOT allocate per call" is a per-iteration claim, and dividing by an unstated count is
# not a claim about anything.
#
# @param iterations [Integer] the first loop's count; the second loop runs twice as many
# @yield the call under measurement, over arguments that cannot allocate
# @return [Float] objects per call
# @raise [Dexpace::InvalidArgumentError] unless iterations is a positive Integer
def delta(iterations:, &block)
unless iterations.is_a?(::Integer) && iterations.positive?
raise ::Dexpace::InvalidArgumentError, "iterations must be a positive Integer"
end

# `GC.disable` answers whether the collector was ALREADY disabled, and this is published
# library code a host may call with the collector off: the state it found is the state it
# leaves, so a host that disabled GC around the call does not find it re-enabled.
was_disabled = ::GC.disable
begin
previous = measure(iterations, &block)
ATTEMPTS.times do
current = measure(iterations, &block)
return current if current == previous

previous = current
end
previous
ensure
::GC.enable unless was_disabled
end
end

private

# One two-loop measurement: the warm-up, then `iterations` and `2 * iterations` calls, the
# difference of the two deltas over `iterations`.
#
# `{ yield }` and never `times(&)`: Integer#times hands its index to the block it is given,
# and the caller's block -- a lambda, say -- may take no argument.
# rubocop:disable-next Style/ExplicitBlockArgument -- see above
def measure(iterations)
WARMUP_ITERATIONS.times { yield }
first = ::GC.stat(:total_allocated_objects)
iterations.times { yield }
second = ::GC.stat(:total_allocated_objects)
(iterations * 2).times { yield }
third = ::GC.stat(:total_allocated_objects)
((third - second) - (second - first)) / iterations.to_f
end
end
end
end
57 changes: 57 additions & 0 deletions gems/dexpace-conformance/lib/dexpace/conformance/assertion.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT

require "dexpace/model"
require "dexpace/error/invalid_argument_error"

module Dexpace
module Conformance
# One conformance check: the requirement IDs it exercises -- a waiver matches by these, never
# by #name -- a human name, and a callable body taking one subject (a TransportCase for the
# transport suite; phase 9 adds suites whose subject is not a transport, which is why the body
# is typed `^(untyped) -> void` and not against a named interface).
#
# Public API crossing into a consumer's own suite, so it follows the phase-1 construction
# pattern: `.new` private, a validating keyword `.build`, `Model.required!`'s one message form
# (SEAM-29), the ids copied through `Model.own` so a caller's later mutation cannot reach them,
# and `#with` through `.build` on every interpreter (Model#with).
class Assertion < Data.define(:ids, :name, :body)
include Model

private_class_method :new

# The validating factory every construction path goes through.
#
# @param ids [Enumerable<String>] the requirement IDs this assertion exercises, non-empty
# @param name [String] a human name, used for the generated test method's name
# @param body [#call] the check, taking one subject
# @return [Assertion] frozen
# @raise [Dexpace::InvalidArgumentError] naming the member, on any invalid value
def self.build(ids:, name:, body:)
new(ids: ids, name: name, body: body)
end

def initialize(ids:, name:, body:)
required = Model.required!("ids", ids).to_a
unless !required.empty? && required.all?(::String)
raise InvalidArgumentError, "ids must be a non-empty collection of requirement-ID Strings"
end
raise InvalidArgumentError, "body must respond to #call" unless
Model.required!("body", body).respond_to?(:call)

super(ids: Model.own(required), name: Model.frozen_string(Model.required!("name", name)),
body: body,)
end

# Runs the check against one subject, returning whatever the body returns; the protocol's
# signal is what it RAISES -- nothing, a Failure or a Vacuous -- and TransportSuite.run
# maps that onto a Result.
#
# @param subject [Object] what the suite hands each assertion
# @return [Object] the body's return value, of no significance to the protocol
def call(subject)
body.call(subject)
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT

require "dexpace/model"
require "dexpace/error/invalid_argument_error"

module Dexpace
module Conformance
# What a driver's `borrow:` factory returns (suite contract 4a): the transport wrapping the
# caller's own native client, and a probe answering "is that client still usable?". The probe
# is the adapter's, because only the adapter knows what using its native client looks like --
# a Net::HTTP round trip on one, an Async::HTTP::Client one on the other -- and that is what
# keeps TRANSPORT-15's borrowed half portable across adapters that share no client class, and
# what keeps a native client class out of this gem's lib/ altogether.
class BorrowedPair < Data.define(:transport, :probe)
include Model

private_class_method :new

# The validating factory every construction path goes through.
#
# @param transport [Object] the borrowing transport, wrapping the caller's own client
# @param probe [#call] answers truthy while the caller's client still works
# @return [BorrowedPair] frozen
# @raise [Dexpace::InvalidArgumentError] naming the member
def self.build(transport:, probe:)
new(transport: transport, probe: probe)
end

def initialize(transport:, probe:)
Model.required!("transport", transport)
raise InvalidArgumentError, "probe must respond to #call" unless
Model.required!("probe", probe).respond_to?(:call)

super
end

# Whether the caller's own client still works, per the adapter's probe -- asked after the
# borrowing transport was closed, which is TRANSPORT-15's whole borrowed clause.
#
# @return [Boolean]
def still_usable?
probe.call ? true : false
end
end
end
end
32 changes: 32 additions & 0 deletions gems/dexpace-conformance/lib/dexpace/conformance/failure.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT

module Dexpace
module Conformance
# The assertion protocol's failure (design §9.3): what an assertion raises instead of returning
# cleanly, carrying the expected and actual values it compared and the requirement IDs it was
# checking. A TEST RESULT, not an SDK error -- P8-8 records why this is a ::StandardError that
# does NOT include Dexpace::Error: phase 1 made that module the root every SDK error includes so
# a caller can `rescue Dexpace::Error` broadly, and an adapter author's broad rescue around a
# send must not swallow the assertion that the send was wrong.
class Failure < ::StandardError
# @return [Object] what the assertion expected
attr_reader :expected
# @return [Object] what it found
attr_reader :actual
# @return [Array<String>] the requirement IDs the assertion was checking, frozen
attr_reader :requirement_ids

# @param message [String] the failure, in words
# @param expected [Object] what the assertion expected
# @param actual [Object] what it found
# @param requirement_ids [Array<String>] the IDs it was checking
def initialize(message, expected:, actual:, requirement_ids:)
@expected = expected
@actual = actual
@requirement_ids = requirement_ids.dup.freeze
super(message)
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT

require_relative "failure"
require_relative "vacuous"
require_relative "transport_case"

module Dexpace
module Conformance
# The thin Minitest driver (design §9.3): `extend` it into a test class and call
# `conformance(suite, build:)`, and one test method per assertion is defined. Each generated
# test runs its OWN assertion directly, in a fresh TransportCase built from the same settings
# every other assertion gets -- so a Minitest `-n` filter exercises exactly the code path a
# full run does, never a replay of a cached Result -- and tears the case down in an ensure. A
# Vacuous is reported as a skip naming the reason, a Failure as a flunk naming the ids, and a
# waived id as a skip naming it, which is design §9.3's "the gap stays visible" in Minitest's
# own summary; anything else propagates as an error and is never silently a failure.
#
# Named MinitestDriver and not Minitest: inside module Dexpace::Conformance a constant named
# Minitest would shadow ::Minitest for every bare reference in the namespace, this file's
# included (5a's P5-3 reasoning, applied a second time). Nothing here requires or names the
# framework: `skip` and `flunk` are sent to the test instance the consumer's class already is,
# so this gem stays at `dexpace-core` and nothing else (NFR-2) and a Minitest that is a bundled
# gem (verified fact 17) is never a runtime constraint on a consumer.
#
# The consumer's test class should inherit the repository's own base where it has one -- the
# first-party driver inherits DexpaceTestCase, whose teardown counts threads, so a leaked
# fixture thread fails the generated test that leaked it.
module MinitestDriver
# @param suite [#assertions] anything shaped like TransportSuite: an ordered #assertions
# @param build [#call] the keyword-taking factory building the SDK-managed transport
# @param borrow [#call, nil] the port-taking factory returning a BorrowedPair, or nil
# @param waive [Array<String>] requirement IDs recorded as skipped and never run
# @param settle [#call, nil] suite contract clause 8's send primitive; nil takes the default
# @param around [#call, nil] clause 9's wrapper around each assertion's invocation
# @param wire [#call, nil] clause 11's fixture factory; nil takes WireServer
# @return [void]
def conformance(suite, build:, borrow: nil, waive: [], settle: nil, around: nil, wire: nil)
case_options = { build: build, borrow: borrow, settle: settle || TransportCase::DEFAULT_SETTLE,
wire: wire || TransportCase::DEFAULT_WIRE, }.freeze
suite.assertions.each do |assertion|
define_method(MinitestDriver.method_name_for(assertion)) do
MinitestDriver.drive(self, assertion, around, case_options, waive: waive)
end
end
end

# The generated method's name: `test_` plus the assertion's name with every non-word run
# collapsed to one underscore, so `-n` can select it and Minitest's naming rule holds.
#
# @param assertion [Assertion]
# @return [String]
def self.method_name_for(assertion)
"test_#{assertion.name.gsub(/\W+/, "_").gsub(/\A_|_\z/, "")}"
end

# One assertion in one fresh case on the given test instance, torn down whatever happened
# -- or a skip naming the waived id, before any case is built. Clause 9: the driver INVOKES
# the assertion, so it may wrap it -- how an async driver opens the reactor the assertion's
# body reads a streamed response inside.
#
# @api private
def self.drive(test, assertion, around, case_options, waive:)
waived = assertion.ids & waive
return test.skip("waived: #{waived.join(", ")}") unless waived.empty?

kase = build_case(case_options)
around ? around.call { assertion.call(kase) } : assertion.call(kase)
rescue Vacuous => error
test.skip("vacuous: #{error.reason}")
rescue Failure => error
test.flunk("#{assertion.ids.join(", ")}: #{error.message}")
ensure
kase&.teardown
end

# The fresh case each generated test builds from the driver's frozen options.
#
# @api private
# @return [TransportCase]
def self.build_case(case_options)
TransportCase.new(build: case_options.fetch(:build), borrow: case_options[:borrow],
settle: case_options.fetch(:settle), wire: case_options.fetch(:wire),)
end
end
end
end
Loading
Loading