diff --git a/.rubocop.yml b/.rubocop.yml index bc0efbe..14ae1b1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -18,6 +18,7 @@ require: - ./.rubocop/cops/dexpace/no_locale_case_fold.rb - ./.rubocop/cops/dexpace/no_thread_interrupt.rb - ./.rubocop/cops/dexpace/no_keyword_splat.rb + - ./.rubocop/cops/dexpace/qualified_core_constant.rb AllCops: NewCops: enable @@ -171,3 +172,14 @@ Dexpace/NoKeywordSplat: Enabled: true Include: - "gems/*/lib/**/*.rb" +# Design §9 Addendum A1 (phase 2): a bare Thread/Queue/Mutex/JSON inside these two namespaces +# rebinds to the adapter gem's constant the moment that gem is required, and core's own suite +# never requires it. +Dexpace/QualifiedCoreConstant: + Enabled: true + Include: + - "gems/dexpace-core/lib/dexpace/async/**/*.rb" + - "gems/dexpace-core/lib/dexpace/serde/**/*.rb" + # The seam module itself reopens Dexpace::Serde from one directory up, so a bare JSON there + # is the same hazard as one inside serde/. + - "gems/dexpace-core/lib/dexpace/serde.rb" diff --git a/.rubocop/cops/dexpace/qualified_core_constant.rb b/.rubocop/cops/dexpace/qualified_core_constant.rb new file mode 100644 index 0000000..ae7a9e3 --- /dev/null +++ b/.rubocop/cops/dexpace/qualified_core_constant.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +module RuboCop + module Cop + module Dexpace + # Inside `module Dexpace::Async` and `module Dexpace::Serde`, a bare `Thread`, `Queue`, + # `Mutex`, `SizedQueue`, `ConditionVariable` or `JSON` is a silent time bomb: it resolves to + # Ruby's class until the adapter gem that defines `Dexpace::Async::Thread` or + # `Dexpace::Serde::JSON` is required, and to that module afterwards. + # + # Verified on Ruby 3.2.11 and 4.0.6: before the adapter loads, a bare `Thread` inside + # `module Dexpace; module Async` is `Thread`; after, it is `Dexpace::Async::Thread`, and + # `Thread.new` raises `NoMethodError: undefined method 'new' for module + # Dexpace::Async::Thread`. dexpace-core's own suite never requires that gem, so this is a bug + # that cannot fail in the tree that contains it -- which is what a cop is for. + # + # Scoped by `Include:` in .rubocop.yml to gems/dexpace-core/lib/dexpace/{async,serde}/**/*.rb. + # + # The enclosing namespace is checked here rather than left to `Include:` in .rubocop.yml. + # Phase 0's cop harness runs a bare Commissioner over a fixed path with a Config carrying + # only TargetRubyVersion, so no `Include:` ever applies to a cop under test -- a cop that + # relied on path scoping would flag its own accepted case and the suite would say so. + # `Include:` stays in .rubocop.yml anyway, because it is what keeps the cop off the other + # five gems during a real run. + # + # SHADOWED is one list for both watched namespaces rather than one list each, so a bare + # `JSON` inside Dexpace::Async is flagged even though only Dexpace::Serde reopens JSON. Two + # per-namespace lists would be a second rule to keep in step for no gain, and the false + # positive costs one `::`. + # + # @example + # # bad -- inside module Dexpace; module Async + # Queue.new + # + # # good + # ::Queue.new + # + # # good -- a different namespace; nothing reopens Dexpace::Transport::Thread + # Thread.new + class QualifiedCoreConstant < Base + MSG = "Write `::%s` here: a bare `%s` inside %s rebinds to the " \ + "adapter gem's constant once that gem is required." + + SHADOWED = %w[Thread Queue Mutex SizedQueue ConditionVariable JSON].freeze + + WATCHED = [%w[Dexpace Async], %w[Dexpace Serde]].freeze + + def on_const(node) + return if node.namespace # already qualified: `A::Thread` or `::Thread` + return unless SHADOWED.include?(node.short_name.to_s) + + namespace = watched_namespace(node) + return if namespace.nil? + + add_offense(node, message: format(MSG, name: node.short_name, namespace: namespace)) + end + + private + + # @return [String, nil] the watched namespace this node is lexically inside, or nil + def watched_namespace(node) + path = lexical_path(node) + watched = WATCHED.find { |segments| path.each_cons(segments.length).any?(segments) } + watched&.join("::") + end + + # Outermost-first, with a compact `module A::B` split into its segments, so that both + # `module Dexpace; module Async` and `module Dexpace::Async` read the same. A + # `class << self` is an sclass node, not a class node, so it contributes no segment. + def lexical_path(node) + node.each_ancestor(:module, :class) + .map { |scope| scope.identifier.source } + .reverse + .flat_map { |name| name.split("::") } + end + end + end + end +end diff --git a/.rubocop/test/cops_test.rb b/.rubocop/test/cops_test.rb index 90b4677..f6b643a 100644 --- a/.rubocop/test/cops_test.rb +++ b/.rubocop/test/cops_test.rb @@ -5,7 +5,8 @@ # The original five cops and Dexpace/NoKeywordSplat, which between them mechanise CLAUDE.md's # ban list and NFR-13. One row per case, one generated Minitest method per row, so a failure -# names exactly one input. +# names exactly one input. Phase 2's sixth cop has its own tables in the nested class below, +# because Metrics/ClassLength caps one class at 100 lines and counts a table row as a line. class CopsTest < CopCase D = RuboCop::Cop::Dexpace HEADER = "# frozen_string_literal: true\n# SPDX-License-Identifier: MIT\n\n" @@ -127,4 +128,55 @@ class CopsTest < CopCase assert_no_offense(cop, source) end end + + # Design §9 Addendum A1 (phase 2): a bare Thread/Queue/Mutex/JSON inside Dexpace::Async or + # Dexpace::Serde rebinds to the adapter gem's constant the moment that gem is required, and + # core's own suite never requires it. The compact `module Dexpace::Async` form is the same + # lexical path and is flagged too; one SHADOWED list serves both namespaces, so a bare JSON + # inside Dexpace::Async is flagged even though only Dexpace::Serde reopens JSON. + class QualifiedCoreConstantTest < CopCase + ASYNC = "module Dexpace\n module Async\n %s\n end\nend\n" + SERDE = "module Dexpace\n module Serde\n %s\n end\nend\n" + NESTED = "module Dexpace\n module Async\n class Completer\n def initialize\n " \ + "@gate = %s\n end\n end\n end\nend\n" + + # [source, the fragment the message must carry] + REJECTED = [ + [format(ASYNC, "Thread.new"), "Write `::Thread` here"], + [format(ASYNC, "Queue.new"), "Write `::Queue` here"], + [format(ASYNC, "Mutex.new"), "Write `::Mutex` here"], + [format(ASYNC, "SizedQueue.new(2)"), "Write `::SizedQueue` here"], + [format(ASYNC, "ConditionVariable.new"), "Write `::ConditionVariable` here"], + [format(SERDE, "JSON.generate(value)"), "Write `::JSON` here"], + ["module Dexpace::Async\n Thread.new\nend\n", "Write `::Thread` here"], + [format(ASYNC, "JSON.parse(text)"), "Write `::JSON` here"], + # A class nested inside the namespace is still lexically inside it, and `Thread::Queue` + # is a bare `Thread` with `Queue` hanging off it. + [format(NESTED, "Thread::Queue.new"), "Write `::Thread` here"], + ].freeze + + # The accepted half is what proves the cop does not reject every occurrence of the six names. + ACCEPTED = [ + format(ASYNC, "::Thread.new"), + format(ASYNC, "::Queue.new"), + format(SERDE, "::JSON.generate(value)"), + "module Dexpace\n module Transport\n Thread.new\n end\nend\n", + "Thread.new\n", + format(ASYNC, "Dexpace::Async::Thread.new"), + format(NESTED, "::Thread::Queue.new"), + format(SERDE, "class << self\n def a = ::JSON\n end"), + ].freeze + + REJECTED.each_with_index do |(source, fragment), index| + test "rejects case #{index}: #{source.lines.first.strip}" do + assert_offense(D::QualifiedCoreConstant, source, fragment) + end + end + + ACCEPTED.each_with_index do |source, index| + test "accepts case #{index}: #{source.lines.first.strip}" do + assert_no_offense(D::QualifiedCoreConstant, source) + end + end + end end diff --git a/CLAUDE.md b/CLAUDE.md index e4a85d0..178fd4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,12 +13,20 @@ three-state PATCH — solved exactly once, and it deliberately does not compete Work here is **spec-driven, not feature-driven**. `docs/product-spec/` is normative: 645 numbered requirements across 19 prefixes. Before implementing anything, find the requirement IDs it must satisfy. -**Phases 0 and 1 are built; the domain model is the only domain code.** Six gems exist under `gems/`, every one -at `0.0.0`. `dexpace-core` carries the HTTP domain model — `Dexpace::Request`, `Response`, `Headers`, `Status`, -`Method`, `Protocol`, `MediaType`, `Query`, `RequestOptions`, `HeaderName`, the `HeaderSyntax`, `PercentEncoding` -and `URL` function modules, and the construction contract `Dexpace::Model` / `Dexpace::Builder` under one error -root, `Dexpace::Error` (`docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md`); every -other gem's `lib/` still holds its namespace module and a `VERSION` constant and nothing else. The workspace root +**Phases 0, 1 and 2 are built; the domain model and the seam layer are the only domain code.** Six gems exist +under `gems/`, every one at `0.0.0`. `dexpace-core` carries the HTTP domain model — `Dexpace::Request`, +`Response`, `Headers`, `Status`, `Method`, `Protocol`, `MediaType`, `Query`, `RequestOptions`, `HeaderName`, the +`HeaderSyntax`, `PercentEncoding` and `URL` function modules, and the construction contract `Dexpace::Model` / +`Dexpace::Builder` under one error root, `Dexpace::Error` +(`docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md`) — and the seam layer: the +provider registry `Dexpace::Registry`, the three seams `Dexpace::Transport`, `Dexpace::AsyncTransport` and +`Dexpace::Serde` with their `.conforms?` predicates and RBS interfaces, the two `SEAM-18` bridges under +`Dexpace::Bridge`, the core-owned async pivot `Dexpace::Async::Future` / `Completer` / `Settlement`, the +cooperative `Dexpace::Cancellation` token and its `Source`, `Dexpace::Closeable` with `Dexpace.close_quietly`, +the seam failure types `SeamError` / `ClosedError` / `CancelledError` and the `Serde::Error` hierarchy, and the +operation projection `Dexpace::Operation` +(`docs/work/mvp/phase2/2026-09-07-phase2-seam-foundations-checklist.md`); every other gem's `lib/` still holds +its namespace module and a `VERSION` constant and nothing else. Nothing talks to a socket yet. The workspace root carries the `Gemfile`, `Rakefile`, `Steepfile`, `rbs_collection.yaml`, `.rubocop.yml`, `.yardopts`, `VERSIONS` and the seventeen blocking gates (`docs/work/mvp/phase0/2026-09-05-phase0-scaffold-and-quality-gates-checklist.md`). Beyond that, what exists is the specification, the port design, the process tooling and the register, @@ -348,6 +356,20 @@ Each is one line plus the chapter to read before touching the area. (§4, §9). - **Regexp timeouts are per-pattern** — `Regexp.new(source, timeout:)`, never the process-global `Regexp.timeout`; a library must not impose a process-wide regexp budget on its host (§4, §6.3). +- **Inside `Dexpace::Async` and `Dexpace::Serde`, write `::Thread`, `::Queue`, `::Mutex`, `::SizedQueue`, + `::ConditionVariable` and `::JSON`** — a bare name resolves to Ruby's class until `dexpace-async-thread` or + `dexpace-serde-json` is required and to that gem's module afterwards, and core's own suite never requires + either. The seventh custom cop, `Dexpace/QualifiedCoreConstant`, enforces it over `lib/dexpace/async/`, + `lib/dexpace/serde/` and `lib/dexpace/serde.rb`; a stand-in `Dexpace::Async::Thread` in + `future_shadowing_test.rb` proves it behaviourally (phase 2's design, §9 addendum A1). +- **`SEAM-27`'s base-URL composition is a concatenation, not RFC 3986 reference resolution** — + `URI::RFC3986_PARSER.join("https://host/c?sig=1", "/pets")` is `https://host/pets`, dropping the base path + segment and the query the requirement keeps; `Dexpace::Operation` composes by hand. Reference resolution is + `REDIR-13`'s, phase 6, and its spelling there is `URI::RFC3986_PARSER.join`, because `URI.join` is cop-banned + (phase 2's design, §3 addendum A1, deviation P2-3). +- **A public `Data` follows the construction pattern without exception; only a `private_constant` snapshot is + exempt** — `Registry::State`, `Registry::Claim` and `Cancellation::Source::State` are `Data` without `Model` + and without `.build`, because a snapshot has no public constructor and no derivation (phase 2's P2-9). ## Public API surface @@ -446,16 +468,18 @@ probe compares each against the live tree, and a count written anywhere else in `dexpace-transport-net_http`, `dexpace-transport-async_http`, `dexpace-serde-json`, `dexpace-async-thread` and `dexpace-conformance`. Each has a gemspec reading `VERSIONS`, a `sig/` mirroring its `lib/` one file per file, a smoke suite, a README, a LICENSE copy and a per-gem `Rakefile`. `dexpace-core`'s `lib/` holds - the phase-1 HTTP domain model — twenty-two files under `lib/dexpace/`, every one mirrored in `sig/` and - `test/`; every other gem is a phase-0 skeleton whose `lib/` holds the namespace module and a `VERSION` - constant and nothing else. Every adapter gemspec declares `dexpace-core` and no third-party gem yet + the phase-1 HTTP domain model and the phase-2 seam layer — forty-two phase-1 and phase-2 files under + `lib/dexpace/` beside phase 0's `version.rb`, every one mirrored in `sig/`, and every one of the forty-two + but the `private_constant` `hooks.rb` mirrored in `test/`; every other + gem is a phase-0 skeleton whose `lib/` holds the namespace module and a `VERSION` constant and nothing + else. Every adapter gemspec declares `dexpace-core` and no third-party gem yet (design P0-9); the third-party half of each `NFR-2` budget arrives with the phase that writes the code needing it. - There are eleven phase directories under `docs/work/*/`; `mvp/` is the only delivery, and it holds the v1 roadmap, `docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md`, plus `phase0/`, - `phase1/`, `phase2/`, `phase3/`, `phase4/`, `phase5/`, `phase6/`, `phase7/`, `phase8/`, `phase9/` and `phase10/`. `phase0/` and `phase1/` each carry - that phase's design, plan and checklist — the two checklists written so far, each at implementation; - `phase2/` carries its design and plan; `phase3/` carries its segmentation design, + `phase1/`, `phase2/`, `phase3/`, `phase4/`, `phase5/`, `phase6/`, `phase7/`, `phase8/`, `phase9/` and `phase10/`. `phase0/`, `phase1/` and `phase2/` each + carry that phase's design, plan and checklist — the three checklists written so far, each at + implementation; `phase3/` carries its segmentation design, `docs/work/mvp/phase3/2026-09-08-phase3-segmentation-design.md`, and two sub-phase directories — `phase3/phase3a/` and `phase3/phase3b/`, each holding that sub-phase's design and plan; `phase4/` carries its segmentation design, @@ -526,5 +550,5 @@ probe compares each against the live tree, and a count written anywhere else in `gates:sole_parse`) and a ninth probe check for chapter attribution — none of the four built yet — and closes or narrows five `docs/first-release.md` lines while publishing nothing: every gem stays at `0.0.0`. - Every checklist but phase 0's and phase 1's is still to be written at execution time. + Every checklist but phase 0's, phase 1's and phase 2's is still to be written at execution time. - There are 40 harvested topics under `docs/knowledge/harvested/`; the harvest ran here on 2026-09-05. diff --git a/README.md b/README.md index 1910bb6..fadd598 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,13 @@ not compete with `faraday` or `httpx` on the easiest way to fetch a JSON endpoin ## Status -**Phases 0 and 1 are built.** Nothing is published. The repository holds six gems under `gems/`, +**Phases 0, 1 and 2 are built.** Nothing is published. The repository holds six gems under `gems/`, every one at `0.0.0`. `dexpace-core` carries the HTTP domain model — the frozen, validated wire -types every later phase stands on (`docs/sdk-documentation/http.md`); the other five are still -skeletons — a namespace, a `VERSION`, a gemspec, a signature mirror and a smoke suite: +types every later phase stands on (`docs/sdk-documentation/http.md`) — and the seam layer: the +provider registry, the transport and codec seams, the core-owned async pivot, cancellation, the +close contract and the operation projection (`docs/sdk-documentation/seams.md`); nothing talks to +a socket yet. The other five are still skeletons — a namespace, a `VERSION`, a gemspec, a +signature mirror and a smoke suite: | Gem | Namespace | Runtime dependencies today | |---|---|---| diff --git a/docs/README.md b/docs/README.md index 97bf37d..f74f36c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -101,12 +101,14 @@ a different namespace and the probe does not confuse them. Six gems exist under `gems/`, all at `0.0.0` and none published — each a gemspec reading the root `VERSIONS` file, a `sig/` mirror and a smoke suite. `dexpace-core` holds phase 1's HTTP -domain model; the other five are phase 0's skeletons, a namespace and a `VERSION`. The as-built -documentation lands in [`sdk-documentation/`](./sdk-documentation/) as each gem gains code; the -two pages written so far are +domain model and phase 2's seam layer; the other five are phase 0's skeletons, a namespace and a +`VERSION`. The as-built documentation lands in [`sdk-documentation/`](./sdk-documentation/) as +each gem gains code; the three pages written so far are [`sdk-documentation/quality-gates.md`](./sdk-documentation/quality-gates.md), because the gate -set is the thing phase 0 built, and [`sdk-documentation/http.md`](./sdk-documentation/http.md), -because the domain model is the thing phase 1 built. +set is the thing phase 0 built, [`sdk-documentation/http.md`](./sdk-documentation/http.md), +because the domain model is the thing phase 1 built, and +[`sdk-documentation/seams.md`](./sdk-documentation/seams.md), because the seam layer is the thing +phase 2 built. ## Keeping this file true diff --git a/docs/first-release.md b/docs/first-release.md index c3f73fc..5c34dc8 100644 --- a/docs/first-release.md +++ b/docs/first-release.md @@ -9,9 +9,9 @@ constant shadow, and the `AuthDescriptor` carrier decision), `CFG-20`'s cancel-w under the unsatisfied MUSTs, and the `CTX-7`/`CTX-8` drain proof under the post-release triggers. Two more left a second home here beside their primary owner: the Minitest 6 trigger, and the red `gates:bounded_map` blocker phase 10's repair clears. **Nothing has been published.** Since phase 0 landed -(2026-09-14) `gems/` holds the six gems, and since phase 1 (2026-09-15) `dexpace-core` carries the HTTP domain -model; there is still no tag, and no version beyond the `0.0.0` every -gem starts at. +(2026-09-14) `gems/` holds the six gems, since phase 1 (2026-09-15) `dexpace-core` carries the HTTP domain +model, and since phase 2 (2026-09-15) the seam layer as well; there is still no tag, and no version beyond the +`0.0.0` every gem starts at. ## Gems, once they exist diff --git a/docs/sdk-documentation/architecture.md b/docs/sdk-documentation/architecture.md index d325ef0..9cc044b 100644 --- a/docs/sdk-documentation/architecture.md +++ b/docs/sdk-documentation/architecture.md @@ -1,11 +1,12 @@ # Architecture **As-built docs land here gem by gem, as they land in `gems/`.** Phase 0 built the six gem -skeletons and the gate set, and phase 1 the HTTP domain model in `dexpace-core`; the pipeline, -seams and adapters are still to come, so this page is still a stub: it names the pages this tree -will eventually hold and where each one's content will come from, so the plan for the -documentation exists before the documentation does. Two pages are real already, because their -subjects are: [`quality-gates.md`](./quality-gates.md) and [`http.md`](./http.md). +skeletons and the gate set, phase 1 the HTTP domain model in `dexpace-core`, and phase 2 the seam +layer in the same gem; the pipeline and the adapters are still to come, so this page is still a +stub: it names the pages this tree will eventually hold and where each one's content will come +from, so the plan for the documentation exists before the documentation does. Three pages are real +already, because their subjects are: [`quality-gates.md`](./quality-gates.md), +[`http.md`](./http.md) and [`seams.md`](./seams.md). Once `dexpace-core` and the first adapters ship, this page becomes the same kind of front door the sibling Node SDK's `docs/sdk-documentation/architecture.md` is — package by package, seam by seam — and the entries below turn from plain text into real links, one at a time, as each page is @@ -18,6 +19,12 @@ their kin, and how each is frozen at construction and reachable only through a b validating factory. Written against the model phase 1 shipped; derives from `docs/sdk-design-ruby/04-domain-model-construction.md`. +[seams.md](./seams.md) — the interface layer everything else plugs into: what a transport, an +async transport and a codec are, how a provider is registered, installed and resolved, the +core-owned future and cancellation token, the close contract, and how an `Operation` becomes a +`Request`. Written against the layer phase 2 shipped; derives from +`docs/sdk-design-ruby/03-seam-by-seam-idiomatic-mapping.md`. + bodies.md — request bodies as duck-typed producers, response bodies as owned resources the caller must close. Derives from `docs/sdk-design-ruby/04-domain-model-construction.md`, read together with the body-seam deviations recorded in `docs/sdk-design-ruby/10-deliberate-deviations-from-the-reference-contract.md`. @@ -37,7 +44,8 @@ and how to run it locally. Written against the build phase 0 shipped; derives fr `docs/sdk-design-ruby/09-toolchain-and-quality-gates.md`. write-a-transport.md — implementing the `Transport` seam, and proving an implementation against -`dexpace-conformance`. Derives from `docs/sdk-design-ruby/03-seam-by-seam-idiomatic-mapping.md`. +`dexpace-conformance`. Derives from `docs/sdk-design-ruby/03-seam-by-seam-idiomatic-mapping.md`; +the seam's contract itself is already on [seams.md](./seams.md). write-a-serde.md — implementing the `Serde` seam: the serializer/deserializer pair, the `Tristate` PATCH convention, and the four encode profiles. Derives from diff --git a/docs/sdk-documentation/quality-gates.md b/docs/sdk-documentation/quality-gates.md index 142464c..8c9a828 100644 --- a/docs/sdk-documentation/quality-gates.md +++ b/docs/sdk-documentation/quality-gates.md @@ -11,8 +11,8 @@ everything. `bundle exec rake gates:list` prints the names; `bundle exec rake -T | # | Task | Protects | Turned red by | |---|---|---|---| -| 1 | `rubocop` | `NFR-7`, `NFR-13`: style and lint findings are fatal; the six custom cops mechanise `CLAUDE.md`'s ban list | any finding at `--fail-level=convention` | -| 2 | `cops:test` | the custom cops themselves — a cop that silently stops matching stays silent otherwise; the four ban cops see a safe-navigation call (`@thread&.kill`, `name&.downcase(:turkic)`) and the interrupt cop a block-pass (`threads.each(&:kill)`, `(&:exit)`) as well as the plain spelling | `.rubocop/test/cops_test.rb`: 45 rejected and 20 accepted sources | +| 1 | `rubocop` | `NFR-7`, `NFR-13`: style and lint findings are fatal; the seven custom cops mechanise `CLAUDE.md`'s ban list — phase 2's `Dexpace/QualifiedCoreConstant` is the seventh, refusing a bare `Thread`/`Queue`/`Mutex`/`SizedQueue`/`ConditionVariable`/`JSON` inside `Dexpace::Async` and `Dexpace::Serde` | any finding at `--fail-level=convention` | +| 2 | `cops:test` | the custom cops themselves — a cop that silently stops matching stays silent otherwise; the four ban cops see a safe-navigation call (`@thread&.kill`, `name&.downcase(:turkic)`) and the interrupt cop a block-pass (`threads.each(&:kill)`, `(&:exit)`) as well as the plain spelling | `.rubocop/test/cops_test.rb`: 54 rejected and 28 accepted sources, the seventh cop's nine and eight in a nested class of their own | | 3 | `rbs:validate` | `NFR-3`: every `sig/**/*.rbs` parses and resolves, per gem, with `--no-collection` and the signature sets of the stdlib features the require allowlist admits loaded with `-r` (`set` excepted: rbs 4 ships it under `core/`) | an `.rbs` naming an undeclared type | | 4 | `steep` | `NFR-3`: `steep check` over six named targets, `core` strict | a type error in any target | | 5 | `test:gems` | `NFR-5`, `NFR-6`, `NFR-10`: every gem's suite in one `ruby -w -W:deprecated` process, stderr scanned for `warning:`, SimpleCov `minimum_coverage 80` over `gems/*/lib/**/*.rb` | a failing test, a warning at load or run time, a leaked thread, coverage below 80 | diff --git a/docs/sdk-documentation/seams.md b/docs/sdk-documentation/seams.md new file mode 100644 index 0000000..3ee9726 --- /dev/null +++ b/docs/sdk-documentation/seams.md @@ -0,0 +1,231 @@ +# The seam layer + +**As built by phase 2, in `dexpace-core`, written against source on 2026-09-15.** This page says +what plugs into what: what a transport, an async transport and a codec *are*, how one is found, +what a future and a cancellation token do, what closing means, and how an operation description +becomes a `Dexpace::Request`. What each piece is *required* to do is +`docs/product-spec/03-pluggable-seams-and-extension-model.md`; how the design maps it to Ruby is +`docs/sdk-design-ruby/03-seam-by-seam-idiomatic-mapping.md`; the per-requirement proof is +`docs/work/mvp/phase2/2026-09-07-phase2-seam-foundations-checklist.md`. Signatures live in +`gems/dexpace-core/sig/`, and this page does not restate them. Nothing here talks to a socket: +phase 2 ships the contracts and no adapter, so every example below stands a lambda in for the +transport phase 8 ships. + +## A seam is three things + +Every seam is a **duck type** — a documented set of methods, nothing to include and nothing to +inherit — a **`.conforms?` predicate** on the seam's own module, and an **RBS interface** in that +seam's `sig/` file (`_Transport`, `_AsyncTransport`, `_Codec`) that a consumer's own `steep check` +sees at a parameter. An object that already has the shape conforms without an adapter: a bare +lambda is a valid transport. + +| Seam | Module | Duck type | Returns | +|---|---|---|---| +| synchronous transport | `Dexpace::Transport` | `#call(request, options, cancellation)` | a `Dexpace::Response` | +| asynchronous transport | `Dexpace::AsyncTransport` | `#call(request, options, cancellation)` | a `Dexpace::Async::Future` | +| wire codec | `Dexpace::Serde` | `#media_type`, `#dump_string`, `#dump_bytes`, `#dump_to(value, sink)`, `#dump_into(value, buffer, offset:)`, `#load(source, witness)` | — | + +The transport predicates read the callable's parameters: three positionals must fit (a lambda, a +non-lambda proc, a `->(*)` and any object with a `#call` of that shape conform) and a required +keyword does not, because a `#call(request, options, cancellation, must:)` would pass registration +and raise Ruby's `ArgumentError` at the first send. The two transport seams have the *same* call +shape and differ only in return type, which no runtime predicate can see before the first call; so +they are two registries, an adapter names which it registers into, and the bridges below check the +returned value at the first send. `Dexpace::Serde.conforms?` is presence-only — the six methods +answered, nothing about their arity — so a codec whose `#load(source)` takes no witness is admitted +and fails with `ArgumentError` at the first decode; the witness is required by the seam, not +checked by the predicate. There is no executor seam and no auto-activation hook: an executor is +caller-supplied by requirement (`SEAM-18`), and "whatever happens to be installed silently wins" is +the failure the loud resolution branches below exist to prevent. + +## Finding a provider + +Ruby has no classpath to scan, so an adapter **registers itself as a side effect of being +required**: `Dexpace::Transport.register(:net_http, Dexpace::Transport::NetHTTP, core: "~> 0.1")` +from the adapter's own entry file, where `core:` is the same `~> MAJOR.MINOR` its gemspec declares +on `dexpace-core`. The registration is refused at once if the running `Dexpace::VERSION` does not +satisfy it — a mismatched core/adapter pair fails at `require` time, not at the first send — and +only that two-segment pessimistic form is accepted; `>= 0.1` is a caller mistake, not a looser +rule. Core ships every registry empty and never requires an optional gem. + +Resolution is `Dexpace::Transport.resolve` (the same five entry points exist on all three seams: +`register`, `install`, `resolve`, `registered_keys`, `swap`), and it follows one precedence: + +1. An explicitly installed provider — `Dexpace::Transport.install(transport)` — always wins. +2. Otherwise, exactly one registered factory is built once, memoised process-wide, and returned. +3. Zero registered factories raise `Dexpace::SeamError` naming the seam and the install entry + point — and **no gem**, so core never names a concrete implementation, not even in an error. +4. Two or more raise `Dexpace::SeamError` listing every registered key. + +A failed resolution memoises nothing, so a later `require` or `install` takes effect on the next +call. Installing the identical instance twice is a no-op; installing a different instance over an +explicit one raises `Dexpace::InvalidArgumentError` naming both; replacing an auto-resolved +provider that had already been handed out replaces it and emits one `Kernel#warn` (silent under +`-W0`, as an advisory should be). Registering a second factory under an occupied key raises +naming both, so two gems claiming one key are loud and a double `require` is quiet. + +Under contention the registry is safe by shape rather than by discipline: its state is one frozen +snapshot swapped under a mutex held across the swap and nothing else, so reads never block; a +concurrent first access runs the factory exactly once, later arrivals park on the winner's gate +(a `Thread::Queue`, so a fiber scheduler sees the wait); and no factory, `.conforms?` call or +`#inspect` (a conflict message names both objects) ever runs under the lock — a factory may +install, swap or resolve *another* registry freely, and one that resolves *its own* registry gets +a `Dexpace::SeamError` at that call rather than a hang. + +For a test, `Dexpace::Transport.swap(fake) { … }` overrides the resolved provider for the block +and restores the prior state afterwards, with no conflict check. Two things are taken from the +live state rather than restored, on purpose: a registration made inside the block (a `require` +cannot be re-run, so reverting it would lose the adapter for the rest of the process) and the +in-flight resolution claim, so a gate that closed inside the block is never put back for later +callers to park on. The resolved provider itself is restored: a build that completes inside the +block finds the override already in the slot, is discarded and closed through +`Dexpace.close_quietly`, and the first `resolve` after the block builds again. + +## The future and the completer + +`Dexpace::Async::Future` is the core-owned, dependency-free pivot every async adapter settles +through; adapters bridge to it and never replace it, which is what keeps third-party async types +out of every public signature. It is a facade over a `Dexpace::Async::Completer`, the write side, +which is handed only to the producer: + +```ruby +completer = Dexpace::Async::Completer.new +future = completer.future + +Thread.new { completer.fulfil(response) } # or completer.fail(error) + +future.value # blocks on a queue, never spins; raises the failure +future.wait # settles-or-returns, never raises +future.on_settle { |settlement| … } # exactly once; at once if already settled +future.then { |value| value.status } # a derived future; failure and cancellation forward +future.cancel(:caller_gave_up) # cooperative, see below +``` + +Three rules do most of the work. **Settling means writing exactly one of a response or an error** +(`Dexpace::Async::Settlement`), so a future can never succeed with nothing. **A fulfil that loses +the race closes what it was handed** — `completer.fulfil(response)` returns `false` and +`Dexpace.close_quietly(response)` has run — so an orphaned response is released whether or not +the adapter remembered (`SEAM-30`). **Failures are delivered as the identical object**: there is +no wrapper, and `future.value` re-raises what `completer.fail` was given. + +The blocking wait is scheduler-transparent: `#value` pops a `Thread::Queue`, which a registered +`Fiber.scheduler` routes through its `block`/`unblock` hooks instead of parking the OS thread — a +caller inside a reactor awaits without blocking it, and a caller with no scheduler blocks one +thread, as asked. `#value(cancellation: token)` waits under a token; there is no `deadline:` yet +(phase 5 adds it, widening the signature). + +## Cancellation + +`Dexpace::Cancellation` is the third argument of both transport seams and the state a blocking +transport honours. It is cooperative: Ruby's pre-emption primitives are forbidden repository-wide, +so nothing is ever interrupted, and the producer's obligation is **check-after-resume** — after any +operation that may have suspended, and before acting on its result, re-check the token; if +cancelled, close what you hold and settle through the failure channel. + +```ruby +source = Dexpace::Cancellation.source # the write side, kept by whoever may cancel +token = source.token # the read side, handed to a transport + +token.cancelled? # => false +token.check! # raises Dexpace::CancelledError once cancelled +handle = token.on_cancel { |reason| … } # exactly once; at once if already cancelled +handle.detach # withdraws that registration; idempotent +source.cancel(:deadline) # => true the first time, false afterwards + +both = Dexpace::Cancellation.any(client_token, per_call_token) +Dexpace::Cancellation.none # the shared token that can never be cancelled +``` + +The reason is a typed object, never a message: a timeout and a cancellation are told apart by +`reason.class`, out of band. A composed token subscribes to nothing at construction — `#cancelled?` +and `#reason` are computed from the sources, and the winner is the source that cancelled first in +time — so composing a client-lifetime token with a per-call token on every request leaks nothing; +a registration made for a bounded wait (`future.value(cancellation:)` arms one) is detached when +the wait ends. `Completer#on_cancel { |reason| … }` is the producer's hook to abort promptly rather +than only at its next resume point; the future is settled as cancelled *before* the hook runs, so +a raising hook can never leave a waiter blocked. + +## Closing + +Anything the SDK can release responds to `#close`. `Dexpace::Closeable` supplies the whole contract +to a class that includes it, calls `initialize_closeable(owned:)` from its constructor and defines +a private `#release`: `#close` is idempotent (a latch, flipped under a mutex held across the flip +only), `#release` runs exactly once and only when `owned?`, and a `#release` that raises still +leaves the latch flipped and propagates once. **Ownership is a construction-time fact**: a +component that *built* a resource releases it; one that *borrowed* it latches and releases +nothing, so the caller may keep using what it supplied. `Dexpace.close_quietly(resource)` is the +one exit for a close on a cleanup path — null-safe, tolerant of an object with no `#close`, and +never raising over a primary failure (the rescued error is dropped until phase 4 supplies the +suppressed trail). `Dexpace::ClosedError` is the documented post-close failure: a transport that +*owns* the resource it closed raises it from a later send; phase 2 ships the class and the rule, +phase 8's adapters the first raise site. + +## Bridging sync and async + +```ruby +async = Dexpace::Transport.async_over(blocking_transport, executor: pool) # executor required +sync = Dexpace::AsyncTransport.sync_over(async_transport) +``` + +Both live in `Dexpace::Bridge` and both are `Closeable` with `owned: false`: they hold what the +caller supplied and create nothing, so their close releases nothing. `async_over` posts the +blocking send to the caller's executor (a duck type: `#post { … }`; there is intentionally no +default, because a shared global pool would be starved by blocking work), returns the future before +anything fallible runs, routes a raise from the transport *or from `#post` itself* to the failure +channel, checks the token before the send and again after it — closing a response no caller will +receive — and refuses, at the first send, an async transport handed to it by mistake. `sync_over` +calls the async transport, checks that a `Dexpace::Async::Future` came back, and waits on it under +the caller's token; per-call options pass through both bridges as the exact object. + +## From an operation to a request + +`Dexpace::Operation` is a frozen descriptor — an HTTP method, a path template with `{name}` +placeholders, and a projection table mapping each input key to +`[:path | :query | :header | :body, wire_name]` — plus one builder method: + +```ruby +pets = Dexpace::Operation.build( + method: "GET", + template: "/owners/{owner}/pets", + projections: { owner: [:path, "owner"], limit: [:query, "limit"], trace: [:header, "X-Trace"] }, +) +request = pets.build_request( + base_url: "https://host/c?sig=abc", + inputs: { owner: "a/b", limit: 1, trace: "t-1" }, +) +request.url.to_s # => "https://host/c/owners/a%2Fb/pets?sig=abc&limit=1" +``` + +Two checks at two times make "every placeholder has a value" structural: at construction, the set +of `:path` wire names must equal the set of template placeholders (a placeholder with no projection +can never be filled; a `:path` projection naming no placeholder can never be used); at +`#build_request`, every projected path input must have a non-`nil` value, and the error names the +input key and the placeholder. A path value is percent-encoded as a single segment — `"a/b"` is +`a%2Fb` and never two segments — the query is the wire model's own RFC 3986 rendering with one +parameter per value of a repeated projection, headers go through the outbound builder so a CRLF is +refused at assembly, and the body is carried untouched for a codec to encode later. A `nil` query +or header input is absent, exactly as an unsupplied one — an unset optional header is not sent +with an empty value — while a `nil` path input is an error, because a placeholder cannot be left +out; an empty `String` is a value on every side. + +The base-URL composition is a concatenation, **not** RFC 3986 reference resolution: a trailing +slash normalises to one separator, an empty operation path leaves the base untouched, an existing +base query is kept with the operation's appended (a dangling `&` dropped), already-encoded octets +survive verbatim, and a base carrying a fragment, one with no hierarchical part to compose onto +(`mailto:x@y`, `urn:isbn:123`), or one whose query is not RFC 3986 (`?sig=100%`, `?a=[1]` — a +bare `%` or a bracket, which the parser admits) is refused naming it. The template's own literal +text is held to the same path grammar at construction — `/x?y`, `/a b` and `/100%` are refused +naming the template, while `/a%20b` is a path and passes — so whatever the inputs, the failure is +`Dexpace::InvalidArgumentError` and never a stdlib `URI` error escaping the composition. +`URI#merge` would have dropped both the `/c` and the `sig=abc` that a signed base URL needs to +keep. + +## What is deliberately absent + +No transport, codec, executor or pipeline ships here; the in-memory fakes the suites use live in +`gems/dexpace-core/test/support/` and are not public API. No deadline on the future's wait, no +error-disposal route inside `close_quietly`, no lifecycle event from `#close`, no operation +identifier on `Operation`, and no presence-gated activation of any kind — each is owned by a later +phase or by `docs/first-release.md`, and the phase-2 design's "Work Phase 2 Postponed" section says +which. The byte-stream provider seam is retired outright: `IO`, `StringIO` and BINARY `String`s are +the platform, not a dependency. diff --git a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md index b9d396a..2c7bab3 100644 --- a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md +++ b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md @@ -2651,3 +2651,44 @@ the table (38 ✅, not 36); two nits were taken — a non-finite or non-real tim `#with` are the SDK's error — and one was routed, `URL.parse!`'s host-less and non-HTTP shapes, as the thirty-sixth phase-10 inbound bullet above. `test:gems` is at 273 runs and 100% line coverage on 4.0.6 and 3.2.11 afterwards. + +**2026-09-15** — **Phase 2 implemented**, as three stacked branches against issue #9: code, tests, +documentation. `dexpace-core` now carries the seam layer beside phase 1's domain model — twenty new `lib/` +files under `lib/dexpace/`, exactly the design's Module Layout, each with its `sig/` mirror and each but the +`private_constant` `hooks.rb` with its `test/` mirror, plus five further suites, eight test-support files +(the three in-memory fakes the roadmap's constraint 4 asks for, three companions one class per file, the +probe scheduler and the warning capture) and the seventh custom cop, `Dexpace/QualifiedCoreConstant` — and +the other five gems are still phase-0 skeletons at `0.0.0`; nothing talks to a socket. The checklist is at +`docs/work/mvp/phase2/2026-09-07-phase2-seam-foundations-checklist.md`: thirty rows, 23 ✅ (`SEAM-15` and +`SEAM-25` with a named gap each — no raise site, no lifecycle event — and `SEAM-29` in phase 1), 3 ⏳ +(`SEAM-12` to phase 8a's `TransportSuite`, `SEAM-24` post-v1, `SEAM-28` to phase 5c Task 4), 3 🚫 (`SEAM-3`, +`SEAM-4`, `SEAM-22`'s mechanism with its surviving clause ✅), 1 N/A (`SEAM-10`, the version-skew guard +phase 0 postponed to this phase built in its place as `Registry#register`'s required `core:` keyword, Tasks 7 +and 8, and exercised under `ruby --disable-gems` on 4.0.6). `bundle exec rake` is green on 4.0.6 with 99.93% +line coverage against the 80% floor (the one uncovered line is the registry claim swap's race-only branch); +the matrix set is green on 3.2.11; the whole suite is green under three further seeds. **Every concurrency +guard the plan's verification table lists was run red** by reverting its fix in the built tree — the +re-entrant-resolve trio and the token-less bridge wait as hangs under the coreutils `timeout`, the rest as +the assertions the plan predicted — and the shadowing test fails `NameError: uninitialized constant +Dexpace::Async::Thread::Mutex` with one `::` removed, the same line the cop flags. **Two of the plan's proofs +were found vacuous as written and repaired in place**: the version-skew grid varied only the requirement, +and against a core at `0.0.0` that cannot tell `>=` from `==` on the minor — the plan's own break-it step +stayed green — so the test now swaps the running `Dexpace::VERSION` across six versions and adds an +exhaustive square (checklist deviation 8); and the method-level-`else` guard's `:not_a_token` scenario is +rescued in the body once the pre-dispatch check exists, so it drives a `nil`-returning transport instead, +which is `SEAM-16`'s null-success rule arriving as a real raise (deviation 12). Thirty departures from +the plan's text are itemised in the checklist, none lowering a gate; two are structural — `Completer#settle` +steals the abort hooks in the same swap that publishes the outcome, so a producer's abort hook fires only +when the cancellation actually won (deviation 2), and `Operation`'s checks and composition live in two +`private_constant` modules so each half is reviewable alone (deviation 7) — and one corrects a phase-0 gate +test that had pinned a phase-0 fact: adapter manifests began with the shared namespace line only because +core did not yet define `Dexpace::Transport`, `Serde` or `Async`, and the corrected assertion is the +property the test's own comment states (deviation 24, on the code branch because that branch's `test:gates` +must be green). `sig/dexpace/hooks.rbs` exists after all, because the strict `core` Steep target checks every +`lib/` file and never relaxes (deviation 1). The four notes the design filed stand; no fifth was needed. The +six postponed items keep their owners. The counts that changed: `gems/` is still six; `dexpace-core`'s +`lib/dexpace/` is forty-two phase-1 and phase-2 files beside phase 0's `version.rb`; `phase2/` now carries +its checklist, the third written; the surface manifest is 318 lines, and the four adapter manifests each +lost the one namespace line core now owns. +`CLAUDE.md`'s built-phases paragraph, its gem and phase-directory sentences and three new constraints-that-bite +lines are rewritten from what was built. diff --git a/docs/work/mvp/phase2/2026-09-06-phase2-seam-foundations-design.md b/docs/work/mvp/phase2/2026-09-06-phase2-seam-foundations-design.md index 7a50e2c..2944300 100644 --- a/docs/work/mvp/phase2/2026-09-06-phase2-seam-foundations-design.md +++ b/docs/work/mvp/phase2/2026-09-06-phase2-seam-foundations-design.md @@ -1365,6 +1365,86 @@ design §10; it is frozen. | P2-14 | `Cancellation#on_cancel` returns a `Cancellation::Subscription` handle rather than `self`, and `Cancellation::Source` gains a public `#off_cancel(hook)` | design §3.3; `SEAM-13`, `SEAM-18`; `NFR-4` | The design describes registration and says nothing about withdrawing one, which leaves `Completer#await` no way to detach the hook it arms on the caller's token. That hook reaches the `Completer` and through it the response the future settled with, so `.any(client_token, per_call_token)` with `future.value(cancellation:)` — what phase 5a's `deadline:` keyword does on every request — retained one closure and one response per request on the client-lifetime source: measured 200 of 200, and 500 100 KB responses still reachable after `GC.start`, on 3.2.11, 3.4.10 and 4.0.6. Composing without subscribing fixes only the composition half of that leak. The cost is one public constant and one public method, both locked by `NFR-4` at the first release tag, which is why they are here and not in a comment | | P2-15 | `Dexpace::Hooks`, a `private_constant` module supplying the one `notify(hooks, argument)` loop `Cancellation::Source#cancel`, `Completer#settle` and `Completer#request_cancel` all run | design §3.3, §3.7; `SEAM-18` | The design describes the notification three times and names no home for it, and a bare `hooks.each { |hook| hook.call(…) }` at each site drops every handler after a raising one and propagates to whoever published the state — the `SEAM-18` "a second waiter blocks forever" failure from the write side, verified on all three interpreters. One implementation runs the whole list and then re-raises the first failure; re-raising rather than dropping, because phase 2 has neither of §3.7's disposal routes (the suppressed trail, phase 4b; §8.1, phase 5) and a handler raising into a void is a bug nothing reports. The failures after the first are attached to the trail by phase 4b, Task 2. It is a `private_constant` and therefore not public API: no `sig/` mirror, no YARD gate entry, no surface-manifest row | +### As built, 2026-09-15 + +Four rows, one predicate and one composition rule stated above read slightly differently against +the source; the difference is recorded here rather than by rewriting the text it corrects. + +- **P2-15, as built.** `lib/dexpace/hooks.rb` *does* have a `sig/` declaration: the strict `core` + Steep target checks every file under `lib/` and refuses an undeclared module, and that target + never relaxes. `sig/dexpace/hooks.rbs` declares the module and its one method with a comment + saying the declaration exists for Steep alone; `Dexpace::Hooks` is still a `private_constant`, + absent from the surface manifest and unreachable from outside `module Dexpace` (asserted in + `dexpace_test.rb`). The row's "no YARD gate entry" holds trivially — the file carries a YARD block + anyway. And `Completer#settle` steals the abort hooks in the same snapshot swap that publishes + the outcome, so a producer's abort hook fires only when the cancellation actually won the race + against its own `#fulfil`; `#request_cancel` is one `settle` call. +- **P2-9, as built.** Two further `private_constant`s exist that are not snapshots: + `Operation::Validation` and `Operation::Composition`, function modules that hold the descriptor's + construction-time checks and `SEAM-27`'s four composition rules so each half is reviewable alone + and the class stays under the length cap. Neither is a `Data`, neither is public, and the rule + the row states — a public `Data` follows phase 1's construction rule without exception — is + unaffected. +- **P2-12, as built.** `WarningCapture` is exactly the block-scoped module the row describes, + prepended after phase 0's raising module; the one `SEAM-8` warning test observes it and the + negative case asserts silence. +- **P2-8, as built.** The cop is the **seventh** in `.rubocop/cops/dexpace/`, not the sixth: + phase 1 added `Dexpace/NoKeywordSplat` after this design was written. The row and the §9 + addendum's title stay as written; `CLAUDE.md`, `docs/sdk-documentation/quality-gates.md` and the + roadmap's status note count seven. +- **The transport predicate, as built (review round 1, 2026-09-15).** The `.conforms?` predicate + stated under `Dexpace::Transport` — "required count ≤ 3 and (a rest parameter is present or + required + optional ≥ 3)" — omits keywords, and as written it admitted + `->(request, options, cancellation, must:) {}`: registration succeeded and the first send raised + Ruby's `ArgumentError: missing keyword` from inside the seam, the failure-at-use the predicate + exists to move to registration. `Registry.accepts_positionals?` now also refuses any `:keyreq` + parameter; an optional keyword, a keyword rest and a block parameter leave the three-positional + call intact and stay admitted. Verified on 3.2.11 and 4.0.6, asserted on both transport seams + and on `Registry.callable?`. The admitted gap stays admitted: the predicate still cannot see the + return type, and `Dexpace::Serde.conforms?` is still presence-only, so a codec whose + `#load(source)` takes no witness registers and fails at the first decode. +- **The composition's malformed-URL rule, as built (review round 2, 2026-09-15).** The + composition table above maps "resolving to a malformed URL" to `URL.parse!` — the base only — + and the construction-time checks it describes are brace balance and placeholder/projection + agreement, so the literal text between placeholders reached `URI::Generic#path=` unchecked: + `Operation.build(method: :get, template: "/x?y")`, `"/a b"`, `"/pets/ü"` and `"/100%"` all + constructed and raised `URI::InvalidComponentError` from `Composition.compose` at the first + `#build_request`, a stdlib error where the plan promises `Dexpace::InvalidArgumentError` for a + malformed template. `Validation.literal!` now checks the template with its placeholders removed + against RFC 3986 `path` at construction, naming the template; the four rules' "a base carrying a + fragment" gained its sibling, a base with no hierarchical part (`mailto:`, `urn:`), which + `URL.parse!` admits as absolute and which leaked `URI::InvalidURIError: path conflicts with + opaque` from the same setter. Path values were never at risk: `encode_component` yields pchars, + and the property test proves it. Verified identical on 3.2.11 and 4.0.6. +- **The composition's malformed-URL rule, the query side (review round 3, 2026-09-15).** Round 2 + validated the template literal and the opaque base and then claimed `Composition.compose` + could not raise; the base's *query* was validated by nothing this phase owns. `URL.parse!` + reaches `URI::Generic#query=`, whose percent check is `/(%\H\H)/` — a `%` followed by two + *non-hex* characters — so a base query ending in a bare `%` or in `%z` (`https://host/c?sig=100%`) + is accepted at parse time, and the composition's `&limit=1` then puts `%&l` in front of the + same check: `URI::InvalidURIError: invalid percent escape: %&l` escaped `#build_request`, the + round-2 property blind to it because it fixes the base at `https://host/c?sig=1`. + `validated_base` now checks the parsed base's query against RFC 3986 `query` (`PATH_LITERAL`'s + set plus `?`, every `%` a two-hex escape) beside the fragment and hierarchical-part rules, + naming the base, whether or not the operation query is empty — so the composition row's four + rules are enforced by three checks of this phase's own plus `URL.parse!`, not by `URL.parse!` + alone. The "cannot raise" claim now rests on both writers' grammars, stated in `compose`'s + comment: RFC 3986 `query` is strictly tighter than `#query=`'s check and `Query#encode` is RFC + 3986 by construction, so no `&` can complete an escape; the parser's `segment` set is + `#path=`'s `ABS_PATH` set and the operation path is `PATH_LITERAL` plus `encode_component` + values, so `#path=` cannot refuse either. Measured as well as argued: a 40,000-base fuzz over + nine base shapes and four operations on 3.2.11 and 4.0.6 found 29,738 bases `URL.parse!` + accepts, 5,031 stdlib leaks before the check and 0 after, identical on both; a 200-sample + base-varying property with a non-empty operation query pins it. Checklist deviation 29. +- **The registry's conflict paths, as built (review round 3, 2026-09-15).** The rule stated + under `Dexpace::Registry` — a mutex held across the snapshot swap and nothing else — was broken + on `#register`'s and `#install`'s conflict paths, whose messages interpolate `#inspect` of two + user objects inside `@write.synchronize`; an `#inspect` reaching back into the registry met + `ThreadError: deadlock; recursive locking` instead of `Dexpace::InvalidArgumentError`. Both now + raise after the block, as `warn_replaced` already did; `#install`'s swap is a private + `#swap_in` reporting the conflicting incumbent and the handed-out flag out. Checklist + deviation 30. + ## Work Phase 2 Postponed, and Who Owns It Now Phase 2 postponed six things. Each entry is self-contained — what was postponed, why phase 2 did not diff --git a/docs/work/mvp/phase2/2026-09-07-phase2-seam-foundations-checklist.md b/docs/work/mvp/phase2/2026-09-07-phase2-seam-foundations-checklist.md new file mode 100644 index 0000000..609e95e --- /dev/null +++ b/docs/work/mvp/phase2/2026-09-07-phase2-seam-foundations-checklist.md @@ -0,0 +1,413 @@ +# Phase 2 — Seam Foundations: Checklist + +**Written at execution time, 2026-09-15, from what was built** — not from the plan. A row whose task +did not do what the plan said is a row that says so, and the "Deviations from the plan" section +below is where each departure is stated with its reason. + +Legend, verbatim from the roadmap's cross-cutting constraint 3: ✅ implemented and tested · +🚫 not built (permanent simplification, named reason) · ⏳ deferred (naming the plan task — phase, +task number and path — that will do it, or the `docs/first-release.md` entry that owns it) · +N/A not applicable in this port. + +Plan: `docs/work/mvp/phase2/2026-09-07-phase2-seam-foundations.md`. Task numbers are that plan's. +Design: `docs/work/mvp/phase2/2026-09-06-phase2-seam-foundations-design.md`, whose Deviation +Ledger rows `P2-n` are cited below. Every test file named here is under `gems/dexpace-core/test/`, +mirrors its `lib/` file one for one, and opens with the IDs it exercises. + +## Requirement rows + +Thirty: `SEAM-1`–`SEAM-30`. + +| ID | Level | Status | Task(s) | What was built, and where it is proven | +|---|---|---|---|---| +| `SEAM-1` | MUST | ✅ | 15, 16 | Phase 0's three zero-dependency gates run green over twenty more `lib/` files: `gates:gemspec_audit` (zero `add_dependency` lines in `dexpace-core.gemspec`), `gates:require_allowlist` (the only `require`s outside `require_relative` are still phase 1's `uri` and `strscan`, both allowlisted; phase 2 added none) and `gates:clean_bundle` (core loads inside a scratch bundle holding only itself, on 3.2.11 and 4.0.6). Every seam registry starts empty on a bare `require "dexpace"` and core never auto-requires an optional gem (`dexpace/seam_surface_test.rb`, `dexpace/transport_test.rb`, `dexpace/async_transport_test.rb`, `dexpace/serde_test.rb`) | +| `SEAM-2` | MUST | ✅ | 7, 9, 10, 12, 15 | Core names no concrete implementation: the three seam modules are duck types with `.conforms?` predicates, and every zero-candidate error names the seam and the install entry point and **no gem** — asserted by pattern against `net_http`, `async_http`, `net/http`, `async-http`, `json`, `oj`, `httpx`, `excon` and `typhoeus` for all three seams (`dexpace/seam_surface_test.rb`, `dexpace/registry_test.rb`). Phase 0's require denylist still runs | +| `SEAM-3` | MUST | 🚫 | 16 | The byte-stream provider seam is retired (design §10.1): Ruby ships `IO`, `StringIO`, `IO.pipe` and `String` with `Encoding::BINARY` with the interpreter, so choosing them is choosing the platform, and the pluggability apparatus exists only to keep a third-party stream library out of a zero-dependency core, which does not apply. The behavioural contract `IO-1`–`IO-42` is **not** retired with it and is phase 3's (`docs/work/mvp/phase3/phase3a/`, `phase3b/`) | +| `SEAM-4` | MUST | 🚫 | 16 | As `SEAM-3`: the provider-resolution rules for a seam that does not exist. `IO-31`–`IO-36`'s resolution story is phase 3's row, not this phase's | +| `SEAM-5` | MUST | ✅ | 7 | `Dexpace::Registry#resolve`'s four branches: an occupied slot returns the instance with no scan; zero factories raise `Dexpace::SeamError` naming the seam and the install hint; exactly one builds, memoises and returns silently; two or more raise listing every key. An explicit `#install` always wins — a registered factory is never called past an installed provider (`dexpace/registry_test.rb`, the top-level class and `Installation`) | +| `SEAM-6` | MUST | ✅ | 7 | `#install` compares `equal?` on the resolved slot: the identical instance is a no-op with no warning; a different instance over an explicit install raises `Dexpace::InvalidArgumentError` naming incumbent and rejected, leaving the incumbent in place. `#register` carries the rule one level down: the `equal?` factory is a no-op, a different factory under an occupied key raises naming both, with no partial side effect. `#swap(provider) { }` is the unchecked test-scoped override seam the requirement permits, block-scoped and restored in an `ensure` (`dexpace/registry_test.rb`, `Installation` and `Swap`) | +| `SEAM-7` | MUST | ✅ | 7 | A successful resolution is memoised process-wide (one factory call across 32 concurrent resolvers); a failed one memoises nothing, so a later `#register` or `#install` takes effect on the next `#resolve` — including after a `LoadError` from the factory, because the single-flight claim is released in an `ensure` and not on a `rescue StandardError` path (`dexpace/registry_test.rb`, the top-level class, `Concurrency` and `Reentrancy`) | +| `SEAM-8` | SHOULD | ✅ | 7 | Replacing an auto-resolved provider that was already handed out emits one `Kernel#warn` naming the replacement and saying objects built against the previous one may still be in use (P2-6, observed through `WarningCapture`, P2-12). Replacing one that was never handed out is silent — the negative clause, reachable only through the unchecked swap seam and asserted there; the test says in so many words that no production path reaches it, because `#resolve` hands out in the same call that resolves (`dexpace/registry_test.rb`, `Installation`) | +| `SEAM-9` | MUST | ✅ | 7 | State is one frozen `Registry::State` snapshot in one instance variable, swapped under a `Thread::Mutex` held across the swap and nothing else; reads are one unsynchronised reference read. Proven: 32 concurrent first accesses run the factory exactly once and receive one `equal?` instance; 16 concurrent installs admit exactly one; a second resolver arriving mid-build parks on the claim's `Thread::Queue` and receives the winner's instance; a factory that takes the registry's own write lock, or a `.conforms?` predicate that does, cannot deadlock because neither is called under it (`dexpace/registry_test.rb`, `Concurrency`). `IO-39`'s lock-free read survives here as a property, not as a row | +| `SEAM-10` | MUST | N/A | 7, 8 | Vacuous in Ruby (design §10.9): one process-global constant namespace, no classloader, `require` de-duplicates by resolved feature path. Replaced by the version-skew guard phase 0 postponed to this phase: `#register(key, factory, core:)` requires the adapter's `~> MAJOR.MINOR` and compares it against `Dexpace::VERSION` by hand (P2-7), because `Gem` is undefined under `ruby --disable-gems` (re-verified on 3.2.11 and 4.0.6 during the build, and the guard exercised under `--disable-gems` on 4.0.6). Any other requirement form — `>= 0.1`, `~> 0.1.2`, `""`, `nil`, a `Symbol` — is refused as `Dexpace::InvalidArgumentError`, never reinterpreted (`dexpace/registry_version_test.rb`, which cross-checks the comparison against `Gem::Requirement#satisfied_by?` over a seeded grid and an exhaustive 6 × 12 square of running versions and requirements) | +| `SEAM-11` | MUST | ✅ | 9 | `Dexpace::Transport`: any object responding to `#call(request, options, cancellation)`; a bare lambda, a non-lambda proc, a `->(*)` and any object with a `#call` of that arity conform; the wrong arity, a non-callable and — since review round 1 — a callable with a required keyword (`->(r, o, c, must:) {}`, which would pass registration and raise `ArgumentError` at the first send) do not, the last asserted on both transport seams and on `Registry.callable?` itself. Single operation: one `#call`, one response. Options may be ignored: an options-ignoring transport returns the same thing for `RequestOptions::EMPTY` and a populated `RequestOptions`, because options are inert immutable data. No pre-buffering is stated at the seam (the module's YARD block) and is phase 8a's to prove over a socket (`dexpace/transport_test.rb`) | +| `SEAM-12` | MUST | ⏳ | 9 | Phase 8a, Tasks 4–8 and 20 (`dexpace-conformance`'s `TransportSuite`, `docs/work/mvp/phase8/phase8a/2026-09-11-phase8a-synchronous-transport-and-conformance.md`). What the seam owes is that nothing forces per-request state onto shared storage — `#call` takes everything it needs and returns everything it produces — and the 32-thread concurrent-call test exercises that harness against the fake. Concurrency safety is a property of an implementation, and this phase ships none | +| `SEAM-13` | SHOULD | ✅ | 4, 9 | Cancellation reaches a transport as the third argument, an ordinary `Dexpace::Cancellation` value and never an ambient interrupt; a transport honours it by re-checking `#cancelled?` at every resume point, and `#check!` is the one-call form of that check. The token, `.none`, `.source`, `.any` and `#on_cancel` are built and tested (`dexpace/cancellation_test.rb`, `dexpace/cancellation/source_test.rb`); `FakeTransport` records the token it was handed (`dexpace/bridge/async_over_test.rb`). Phase 8's adapters honour it; `dexpace-conformance` asserts it | +| `SEAM-14` | MUST | ✅ | 2, 9, 10 | `Dexpace::Closeable`: a latch flipped under a mutex held across the flip only, `#release` run exactly once under 16-way contention, a raising `#release` leaving the latch flipped and propagating once, ownership a frozen construction-time boolean so a borrowed resource latches but is never released, and a loud `Dexpace::SeamError` for an includer that never initialised the latch. Both transports this phase ships — `Bridge::AsyncOver` and `Bridge::SyncOver` — include it with `owned: false`, answer `#close`/`#closed?`/`#owned?`, release nothing and stay usable after close (`dexpace/closeable_test.rb`, `dexpace/bridge/async_over_test.rb`, `dexpace/bridge/sync_over_test.rb`) | +| `SEAM-15` | MAY | ✅ with a named gap | 1, 9 | The MAY is taken and documented: `Dexpace::ClosedError` (Task 1), and the rule — stated in `Dexpace::Transport`'s YARD block and in the class's own — that **an owning transport raises it from a later send** while a borrowing wrapper closes nothing and stays usable (`XCUT-22`). What ships is the class and the rule and **no raise site**: `Dexpace::ClosedError` is referenced nowhere in `lib/` outside its own file, and the tests assert its ancestry and its `rescue Dexpace::Error` catch (`dexpace/error/closed_error_test.rb`, `dexpace/transport_test.rb`). Phase 8's adapters are the first owners; `dexpace-conformance` asserts the raise per adapter (phase 8a's `TransportSuite`) | +| `SEAM-16` | MUST | ✅ | 5, 6, 10 | `Dexpace::AsyncTransport` returns a `Dexpace::Async::Future`. Settling means writing exactly one of a response or an error — `Settlement#initialize` refuses both and neither, and `cancelled` implies an error — so a null success is unrepresentable; a delivered response is never closed by the pivot, and `#cancel` after settlement is a no-op that closes nothing (`ASYNC-20`). `#value` blocks on a `Thread::Queue` pop rather than spinning, proven by a producer that sleeps first, and honours a cancellation token by settling the future as cancelled and raising `Dexpace::CancelledError` carrying the reason (`dexpace/async/settlement_test.rb`, `dexpace/async/completer_test.rb`, `dexpace/async/future_test.rb`, `dexpace/async_transport_test.rb`) | +| `SEAM-17` | SHOULD | ✅ | 5, 6 | The pivot is core-owned and dependency-free (design §10.3, roadmap cross-phase obligation 5): `Completer` holds the state, `Future` is a facade over it, and the third-party async types stay out of every public signature — `gates:rbs_surface` is load-bearing for the first time and was watched go red on a temporary `Async::Task` in `sig/`. The scheduler-transparency claim is asserted rather than restated: a `Fiber.schedule`d consumer blocking in `#value` routes through the probe scheduler's `#block`/`#unblock` hooks for a `Thread::Queue`, and a settled future touches the scheduler not at all (`dexpace/async/future_scheduler_test.rb`, `test/support/probe_scheduler.rb`, with `#fiber_interrupt` defined so 4.0.6 emits no warning). The constant-shadowing proof defines a stand-in `Dexpace::Async::Thread` and re-runs the pivot; with one `::` removed it fails `NameError: uninitialized constant Dexpace::Async::Thread::Mutex` and the cop flags the same line (`dexpace/async/future_shadowing_test.rb`, `.rubocop/cops/dexpace/qualified_core_constant.rb`) | +| `SEAM-18` | MUST | ✅ | 4, 9, 10, 11 | `Dexpace::Transport.async_over(transport, executor:)` — the executor is required, refused without `#post`, and there is intentionally no default — and `Dexpace::AsyncTransport.sync_over(transport)`, both in `Dexpace::Bridge` (P2-13). Clause by clause: the original failure comes back as the identical object, because the pivot never wraps; the blocking wait honours interruption by cancelling the in-flight future and raising `Dexpace::CancelledError` with the reason (P2-4); per-call options arrive at the wrapped transport as the exact object; a raise from `#post` itself is normalised to the failure channel; an async transport handed to `async_over` raises `Dexpace::SeamError` at the first send rather than delivering a future of a future; a token already cancelled when the posted block runs never reaches the transport; and two blocking waits on one token both unblock when it is cancelled, which is the guard `#on_cancel`'s per-registration flag exists for. Nine deliberate breaks were each run red and restored (see "Guards run red" below) (`dexpace/bridge/async_over_test.rb`, `dexpace/bridge/sync_over_test.rb`) | +| `SEAM-19` | MUST | ✅ | 12 | `Dexpace::Serde.conforms?` requires `#media_type` among the six seam methods; the module supplies no default and answers no `media_type` itself, so a codec that forgets it fails conformance — and `Serde.missing_methods` names which of the six it forgot — rather than stamping a wrong `Content-Type`. That the value is correct is phase 7's (`dexpace/serde_test.rb`) | +| `SEAM-20` | MUST | ✅ | 12 | All four allocation profiles are in the contract — `#dump_string`, `#dump_bytes`, `#dump_to(value, sink)` and `#dump_into(value, buffer, offset:)` — and `#dump_bytes` differs from `#dump_string` exactly in the `Encoding::BINARY` tag (design §10.13). The streaming variant never closes the caller's sink and the buffer variant raises `IndexError` on overflow, asserted against `FakeCodec`; `#dump` is a permitted shorthand and deliberately not in the contract. The encode failure type is `Dexpace::Serde::SerializationError` (`dexpace/serde_test.rb`, `dexpace/serde/serialization_error_test.rb`) | +| `SEAM-21` | MUST | ✅ | 12 | `#load(source, witness)` reads to EOF and never closes the caller's source, asserted against the fake; a genuine stream I/O error is not reclassified — `Dexpace::Serde::Error` is deliberately outside `IOError` — and the decode failure type is `Dexpace::Serde::DeserializationError` (`dexpace/serde_test.rb`, `dexpace/serde/deserialization_error_test.rb`) | +| `SEAM-22` | MUST | 🚫 mechanism; surviving clause ✅ | 12 | The reflective generic type capture is a JVM artefact, replaced by the witness protocol (design §10.14), which is §7.3's and phase 7's. The clause that survives the substitution is fixed here: `#load` takes an explicit witness and there is no witness-less overload — `FakeCodec.new.load(source)` is an `ArgumentError` (`dexpace/serde_test.rb`). Read out of appendix C row 28 verbatim, as the roadmap and the design require | +| `SEAM-23` | MUST | ✅ | 12 | `Dexpace::Serde::Error < ::StandardError` including `Dexpace::Error`, with `SerializationError` and `DeserializationError` beneath it: a class root, inverting phase 1's module root deliberately (P2-2), open for an adapter to subclass, caught by `rescue Dexpace::Error`, and chaining the backing library's failure as `#cause` when raised from inside its rescue. Inside `module Dexpace::Serde` a bare `Error` is the seam root, asserted, which is why core writes `Dexpace::Error` fully qualified (`dexpace/serde/error_test.rb`) | +| `SEAM-24` | SHOULD | ⏳ | — | Post-v1, riding on `dexpace-async-async` — `docs/first-release.md` § What v1 ships without › SHOULD- and MAY-level requirements declined for v1, the `SEAM-24` entry. Tasks 4 and 5 fix the contract its bidirectional mapping will map: `Dexpace::Cancellation` in one direction and `Completer#on_cancel` in the other | +| `SEAM-25` | MUST | ✅ with a named gap | 2, 9, 10 | The idempotent, ownership-aware release is `Dexpace::Closeable` (Task 2) and both bridges take it (Tasks 9, 10): only the first close runs `#release`, a caller-supplied transport or executor is never touched. The clause "**and emits the lifecycle event**" has no event to emit until §8.1's instrumentation facade exists — emitted by phase 8b, Tasks 6 and 10 (`docs/work/mvp/phase8/phase8b/2026-09-11-phase8b-async-runtime-adapter.md`), harnessed by phase 9, Task 11 (`docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance.md`). Named rather than claimed | +| `SEAM-26` | MUST | ✅ | 13, 14 | `Dexpace::Operation = Data.define(:method, :template, :projections)` including `Dexpace::Model`: `private_class_method :new`, a validating `.build(method:, template:, projections: {})`, `method` coerced through `Dexpace::Method.of`, the template a frozen `String`, the projections table copied and deep-frozen through `Model.own`. A parameterless GET is the default construction. Validation at construction: a projection is a `[target, wire name]` pair, the target one of `:path`/`:query`/`:header`/`:body` (the error names all four), the wire name non-empty, at most one body projection, and the set of `:path` wire names equal to the set of `{name}` placeholders in both directions. `#with` re-validates on the 3.2 floor through phase 1's `Model#with`. The body is carried, not encoded — the input object arrives on the request as itself (`dexpace/operation_test.rb`, `dexpace/operation_build_request_test.rb`) | +| `SEAM-27` | MUST | ✅ | 14 | `#build_request(base_url:, inputs: {})`. Path values go through phase 1's `PercentEncoding.encode_component`, so `"a/b"` becomes `a%2Fb` and never a second segment — a 40-sample property over an alphabet including `/`, `?`, `#`, `&`, a space, `%` and invalid UTF-8 asserts the assembled URL re-parses through `URL.parse!` with exactly one segment added; a `nil` path input is missing and raises naming the input key and the placeholder, `false` is a value. The query is phase 1's `Query#encode`, one parameter per value of a repeated projection, a structured value refused rather than rendered as an inspect string. Headers go through the outbound `Headers::Builder`, so a CRLF is rejected at assembly. The composition is hand-built (P2-3): the specification's own example `https://host/c?sig=abc` + `/pets` + `limit=1` → `https://host/c/pets?sig=abc&limit=1`, trailing and leading slashes normalised to one separator, an empty operation path leaving the base untouched, a dangling `&` dropped, already-encoded octets surviving verbatim, a fragment on the base rejected naming it, and the base URI left untouched. The three places a stdlib `URI` error could otherwise escape the composition are closed (review rounds 2 and 3): a template whose literal text between placeholders is not an RFC 3986 path — `/x?y`, `/a b`, `/pets/ü`, `/100%`, a bare `%`, `<`, `[`, a quote, a tab, a newline — is refused at construction naming the template, a base with no hierarchical part to compose onto (`mailto:x@y`, `urn:isbn:123`) is refused naming the base, and a base whose query is not RFC 3986 — `?sig=100%`, `?;~%`, `?a=%z`, `?a=[1]`, all of which `URL.parse!` admits — is refused naming the base whether or not an operation query is appended (`?sig=100%25` composes); two 200-sample properties, one over templates drawn from pchars, `/`, `?`, `#`, `%`, braces, a space and `ü` and one over base queries drawn from the same plus `&`, `=`, `;`, `[` and `]`, assert that every input either fails as `Dexpace::InvalidArgumentError` or composes a URL that re-parses to itself. `URI::RFC3986_PARSER.join("https://host/c?sig=1", "/pets")` was re-verified as `https://host/pets` on 3.2.11 and 4.0.6 during the build (`dexpace/operation_build_request_test.rb`) | +| `SEAM-28` | MAY | ⏳ | — | Phase 5c, Task 4 (`docs/work/mvp/phase5/phase5c/2026-09-09-phase5c-tracing-and-metrics.md`), over phase 4a, Task 7's `RequestContext#operation_name` — a target this phase supplied. Both halves need machinery phase 2 does not have: the context chain (`CTX`, phase 4) and a consumer for the identifier (phase 5). Read out of appendix C row 34 verbatim | +| `SEAM-29` | MUST | ✅ in phase 1 | — | A cross-reference row: phase 1's `Dexpace::Model.required!` (the uniform ` is required` message, which `Operation.build(method: nil, …)` and `(template: nil)` produce) and `Dexpace::Builder` (the generic contract `Operation#build_request` builds through, via `Request::Builder`). Not re-satisfied here; `docs/work/mvp/phase1/2026-09-05-phase1-core-http-domain-model-checklist.md` is its row | +| `SEAM-30` | MUST | ✅ | 5, 11 | `Completer#fulfil` on an already-settled future returns `false` **and** closes the response it was handed, exactly once, through `Dexpace.close_quietly` — so the rule holds for every adapter that settles through a `Completer`, and for a mapped value that loses the race in `Future#then`. `Bridge::AsyncOver`'s posted block is the one place phase 2 itself produces a response nobody will receive: it re-checks the token after the send and closes the response before settling through the failure channel, and it checks before dispatch too, so a token already cancelled never reaches the transport. A registry build that loses to a concurrent `#install` is closed the same way (`dexpace/async/completer_test.rb`, `dexpace/async/future_test.rb`, `dexpace/bridge/async_over_test.rb`) | + +Thirty rows: 23 ✅ (two of them with a named gap — `SEAM-15`, `SEAM-25`; one in phase 1 — +`SEAM-29`), 3 ⏳ (`SEAM-12`, `SEAM-24`, `SEAM-28`), 3 🚫 (`SEAM-3`, `SEAM-4`, and `SEAM-22`'s +mechanism, whose surviving clause is ✅), 1 N/A (`SEAM-10`, with the version-skew guard built in +its place) — 23 + 3 + 3 + 1 = 30, recounted from the table. `XCUT-13`, `XCUT-22` and `XCUT-23` +are implemented here — the latch, the ownership rule and the deterministic three-registry +resolution — and are phase 9's to disposition; none is a row. + +## What was built + +Twenty new `lib/` files under `gems/dexpace-core/lib/dexpace/` — exactly the design's Module Layout, +including `hooks.rb` and the two `bridge/` files — each with a `sig/` mirror (twenty, one more than +the design's nineteen: `sig/dexpace/hooks.rbs` exists, see deviation 1) and, for every file but +`hooks.rb`, a `test/` mirror (nineteen), plus five further suites — `async/future_scheduler_test.rb`, +`async/future_shadowing_test.rb`, `registry_version_test.rb`, `operation_build_request_test.rb`, +`seam_surface_test.rb` — and eight files under `test/support/`: the three fakes the roadmap's +constraint 4 asks for (`fake_transport.rb`, `fake_async_transport.rb`, `fake_codec.rb`) with their +companions one class per file (`options_ignoring_transport.rb`, `inline_executor.rb`, +`incomplete_codec.rb`), `probe_scheduler.rb` and `warning_capture.rb`. The cop the design's §9 +addendum A1 calls the sixth — the seventh in the tree, phase 1 having added `Dexpace/NoKeywordSplat` +after the design was written — +`.rubocop/cops/dexpace/qualified_core_constant.rb`, with seventeen cases in `.rubocop/test/cops_test.rb`. +The entry file `lib/dexpace.rb` gained the twenty `require_relative`s as one block in dependency +order; `sig/dexpace.rbs` needed nothing. `require "uri"` and `require "strscan"` are still the only +`require`s outside `require_relative`, both phase 1's; the gemspec still has zero `add_dependency` +lines. + +The gates, all seventeen, on **4.0.6** (`bundle exec rake`, 2026-09-15): green, exit 0 — +`cops:test` 82 runs, `steep` no type error over the strict `core` target, `test:gems` 529 runs / +3376 assertions with **99.93% line coverage (1521/1522)** against the 80% floor (the one uncovered +line is the race-only branch of the registry's claim swap, reachable only when a resolution +completes between a resolver's unsynchronised read and its locked claim), `test:gates` 128 runs, the +nine `gates:*` tasks, `yard` 100.00% documented, `bundler_audit` clean. The same caveat about +`rubocop` that phase 1 recorded: run through `rake` from this worktree it inspects 9 files, because +the parent checkout's `.rubocop.yml` excludes `.claude/**/*`; run as +`bundle exec rubocop --fail-level=convention --ignore-parent-exclusion` it inspected **179 files, no +offenses**, and that is the run these rows rest on (already on phase 10's inbound list from phase 1). + +`test:gems` green on **3.2.11** (529 runs, 99.93% line coverage), with its own lockfile resolved +fresh; the seeded order-independence runs are recorded in the roadmap's status note. The 3.2.11 run +is the one that proves phase 1's `Model#with` still carries this phase's two public `Data` types — +`Settlement` and `Operation` both assert that `#with` re-validates. + +The surface manifest `test/fixtures/surface/dexpace-core.txt` grew from 212 to 318 lines through +`bundle exec rake surface:regenerate`, once, after Task 15; the diff was read line by line and is +exactly the 106 public constants and methods the twenty files define. Checked specifically: no +`REGISTRY` constant appears (all three are `private_constant`), no `Dexpace::Hooks`, +`Dexpace::Cancellation::Subscription` **does** appear, and `Registry::State`, `Registry::Claim`, +`Cancellation::Source::State`, `Operation::PLACEHOLDER`, `Operation::Validation` and +`Operation::Composition` do not — phase 0's generator walks `constants(false)`, which honours +`private_constant`. The four adapter manifests each lost one line — `Dexpace::Transport`, +`Dexpace::Serde` or `Dexpace::Async` — because core now defines those namespaces and an adapter's +manifest is what it adds beyond core's; the gate test that had pinned the phase-0 shape is corrected +(deviation 24). `gates:sig_diff` still prints "no release tag yet". `gates:rbs_surface` was watched +go red on a temporary `Async::Task` in `sig/dexpace/async/future.rbs` and green once removed. + +## Guards run red + +Every concurrency guard the plan's planning-verification table lists, plus Task 6's shadowing break +and Task 11's eight, was run red by reverting its fix in the built tree, on 4.0.6, and restored; +each hang was run under the coreutils `timeout`. What each said: + +| Fix reverted | Guard | What it said | +|---|---|---| +| `#complete_resolution` releases the claim on `rescue StandardError`, not in `ensure` | `registry_test.rb`, `Reentrancy` | "the registry wedged into a spin instead of re-evaluating" (the join timeout) | +| `#swap`'s `ensure` restores the captured snapshot wholesale | `registry_test.rb`, `Swap` | "swap restored a closed gate and wedged the registry" | +| `#swap`'s `ensure` restores `factories` from the snapshot | `registry_test.rb`, `Swap` | "Expected: `[:key]`, Actual: `[]`" | +| `#resolve` does not check the claim's owner | `registry_test.rb`, `Reentrancy`, three tests | run alone: "`[Dexpace::SeamError]` exception expected, not `Class: `" (Ruby's deadlock detector); the trio together: a hang, killed by `timeout` after 120 s | +| the factory call moves back under `@write` | `registry_test.rb`, `Concurrency`, two tests | `ThreadError: deadlock; recursive locking` | +| `Completer#await` does not detach what it armed | `cancellation_test.rb`, `Subscriptions` | "the await armed a hook on a client-lifetime source and never detached it. Expected: 0" | +| `#request_cancel` notifies the abort hooks before publishing the outcome | `async/completer_test.rb`, `Hooks` | "cancellation must always publish an outcome" | +| `Hooks.notify` becomes a bare `each` in `Source#cancel` | `cancellation_test.rb`, `Subscriptions` | "Expected: `[:first, :third]`, Actual: `[:first]`" | +| `Hooks.notify` becomes a bare `each` in `Completer#settle` | `async/completer_test.rb`, `Hooks` | "Expected: `[:first, :third]`, Actual: `[:first]`" | +| `Cancellation.over`'s `is_a?(Source)` guard | `cancellation_test.rb`, `Composition` | "`Dexpace::InvalidArgumentError` expected but nothing was raised" | +| `deliver`'s delivery branch as a method-level `else` | `bridge/async_over_test.rb` | "the future never settled and `#value` would block" (with a `nil`-returning transport, deviation 12) | +| `#on_cancel`'s per-registration guard becomes one token-level flag | `bridge/sync_over_test.rb` | "a waiter never unblocked: SEAM-18's interruption clause is violated", after the five-second join — and with the token still frozen the break cannot even be written: `FrozenError` | +| one `::` dropped from `completer.rb` | `async/future_shadowing_test.rb` | `NameError: uninitialized constant Dexpace::Async::Thread::Mutex`; the cop flags the same line | +| `options` dropped from `AsyncOver#deliver`'s call | `bridge/async_over_test.rb` | "Expected nil to be the same as `#`" | +| the failure wrapped in a `RuntimeError` | `bridge/async_over_test.rb` | "`[IOError]` exception expected, not `Class: `" | +| the check-after-resume branch deleted | `bridge/async_over_test.rb` | "`Dexpace::CancelledError` expected but nothing was raised" | +| `future.value` without `cancellation:` in `SyncOver#call` | `bridge/sync_over_test.rb` | a hang, killed by `timeout` after 120 s | +| the `rescue` around `@executor.post` removed | `bridge/async_over_test.rb` | `IOError: the pool is shut down` escaping `#call` | +| `#deliver`'s `Dexpace::Async::Future` guard deleted | `bridge/async_over_test.rb` | "`Dexpace::SeamError` expected but nothing was raised" | +| `#deliver`'s pre-dispatch check deleted | `bridge/async_over_test.rb` | "check-before-dispatch: the send was never made. Expected `[[:request, nil, …]]` to be empty" | +| `>=` on the minor changed to `==`, then to `>` | `registry_version_test.rb` | the grid: two failures ("Expected: true, Actual: false"); the own-version test: three — **but only once the grid varied the running version** (deviation 8) | + +Review round 1 (2026-09-15) added two tests that were run red against the code branch before its +fix landed, on 4.0.6, and green on 4.0.6 and 3.2.11 after it: + +| Fix not yet applied | Guard | What it said | +|---|---|---| +| `accepts_positionals?` ignores `:keyreq` | `registry_test.rb`, `Callable`; `transport_test.rb`; `async_transport_test.rb` | "callable? refuses a required keyword and admits the optional keyword shapes: Expected true to not be truthy" | +| `render_headers` renders a `nil` input as `""` | `operation_build_request_test.rb`, `Projections` | "Expected `# [""]} …>` to not include "X-Trace"" | + +Review round 2 (2026-09-15) added three more, run the same way — red against an export of the +code branch's tip before the fix, green on 4.0.6 and 3.2.11 after it (deviation 28): + +| Fix not yet applied | Guard | What it said | +|---|---|---| +| the template literal is not checked against the path grammar | `operation_test.rb` | ""/x?y". `Dexpace::InvalidArgumentError` expected but nothing was raised" | +| `validated_base` admits an opaque base | `operation_build_request_test.rb` | "mailto:x@y. `[Dexpace::InvalidArgumentError]` exception expected, not `Class: ` Message: <"path conflicts with opaque">" | +| both, under the 200-sample template property | `operation_build_request_test.rb`, `Templates` | `URI::InvalidComponentError: bad component(expected absolute path component): /c/F/ #ü` escaping `#build_request` | + +Review round 3 (2026-09-15) added three more, run the same way (deviations 29 and 30): + +| Fix not yet applied | Guard | What it said | +|---|---|---| +| `validated_base` admits a base query ending in a bare `%` | `operation_build_request_test.rb` | "https://host/c?sig=100%. `[Dexpace::InvalidArgumentError]` exception expected, not `Class: ` Message: <"invalid percent escape: %&l">" from `operation.rb:318` `Composition#compose` | +| the same, under the 200-sample base property | `operation_build_request_test.rb`, `Bases` | `URI::InvalidURIError: invalid percent escape: %&l` escaping `#build_request` | +| `Registry#register` and `#install` build their conflict message under `@write` | `registry_test.rb`, `Reentrancy` | "`[Dexpace::InvalidArgumentError]` exception expected, not `Class: ` Message: <"deadlock; recursive locking">" from `registry.rb:120` `Registry#register`, and from `registry.rb:144` via `conflict!` inside `#install`'s block | + +## Audit groups run + +The phase-start pair first, at implementation: `--section conflicts --brief` returns the six +harvested conflicts, every one `[overridden by notes/…]`, and nineteen note-side entries; `--origin +note --brief` returns fifty-two note entries across twenty files, none contradicting this plan. +`--prefix-info SEAM` reports 30 IDs, 28 substantive, 0 roll-ups; `--gaps SEAM` names `SEAM-22` and +`SEAM-28` as appendix-C only, with the `grep` line, and both were read out of appendix C verbatim +(rows 28 and 34). `--req` was run for each task's IDs before that task; none came back a roll-up. The +six groups the design ran at planning are recorded there with their results; at implementation the +two that bite were re-checked against the built code rather than re-run in full: + +| Group | Result at implementation | +|---|---| +| Fiber scheduler, thread safety | Clean against the built code: every mutex is held across a flag flip or a snapshot swap and nothing else (`Closeable#close`, `Cancellation::Source#cancel`, `Cancellation#once_only`, `Completer#settle`, `Registry#register`/`#swap_in`/`#take_or_join_claim`/`#swap`/`#complete_resolution`/`#hand_out`); no factory, predicate, callback, `warn` or — since review round 3, deviation 30 — conflict message's `#inspect` runs under one; the pivot's wait and the registry's gate are both `Thread::Queue#pop`; `Timeout.timeout`, `Thread#raise` and `Thread#kill` appear nowhere and phase 0's cop stands guard | +| Public API surface | One rule bit at implementation and is answered in place: `module-organization/1828a984` (one public constant per file) is honoured by the twenty files; the two extra `private_constant` modules inside `Operation` and the three snapshot `Data` types are not public constants. `api-design/b0e18938` is why every public name that arrived by accident is in P2-11 | + +`data-modeling/677b01de`'s `Data`-everywhere rule and P2-9's boundary held: the three snapshots are +`Data` without `Model`; the two public `Data` types — `Settlement`, `Operation` — follow phase 1's +construction rule without exception. The four notes the design filed stand as written; no fifth was +needed. + +## Deviations from the plan + +Departures from the plan's text, each with its reason. None lowers, disables or narrows a gate. One +changes a gate's own test in the corrected direction (item 24). + +1. **`sig/dexpace/hooks.rbs` exists.** The plan says `lib/dexpace/hooks.rb` gets no `sig/` mirror + because `Dexpace::Hooks` is a `private_constant`. Strict Steep on the `core` target checks every + file under `lib/` and refuses an undeclared module, and the target never relaxes; so the module + and its one method are declared, with a comment saying the declaration exists for Steep and the + privacy lives in `lib/`. Phase 1 declared its private constants in `sig/` for the same reason. + `Dexpace::Hooks` is still not public API: not in the surface manifest, unreachable from outside + `module Dexpace` (asserted in `dexpace_test.rb`). +2. **`Completer#settle` steals both callback lists under the lock that publishes the outcome, and + `#request_cancel` is one `settle` call.** The plan's `#request_cancel` took the abort hooks under + the mutex, then settled, then notified — leaving a window in which a concurrent `#fulfil` wins + the race and the producer's abort hook still fires. As built the hooks are taken in the same + swap that writes the outcome, so an abort hook fires only when the cancellation actually won; + on a cancellation the abort hooks run first and the settle callbacks run whatever the abort + hooks did (an `ensure`). The "outcome published before any hook" guard was run red the same way. +3. **The cop's cases are a nested class, `CopsTest::QualifiedCoreConstantTest`**, with its + own tables, rather than rows appended to phase 0's. Seventeen more rows put `CopsTest` at 147 + lines against `Metrics/ClassLength`'s 100, whose inner-class exclusion is the sanctioned shape + (phase 1's test files do the same). Nine rejected and eight accepted cases, the four the plan's + review added included. +4. **Test files nest a class per behaviour group** — `cancellation_test.rb` (`Composition`, + `Subscriptions`), `async/completer_test.rb` (`Hooks`), `async/future_test.rb` (`Then`), + `registry_test.rb` (`Callable`, `Installation`, `Concurrency`, `Reentrancy`, `Swap`), `operation_test.rb` + (`Projections`), `operation_build_request_test.rb` (`Projections`) — for the same cap. +5. **The fakes are one class per file.** `Style/OneClassPerFile` refuses a second top-level class, + so `OptionsIgnoringTransport`, `InlineExecutor` and `IncompleteCodec` have their own files + beside the three fakes; suites require the ones they use. +6. **`Dexpace::Registry` is reshaped internally, not in contract:** `#resolve` is a loop over an + unsynchronised read, a `#take_or_join_claim` swap returning the claim and whether it is fresh, + and a `#wait_on` that raises the re-entrancy error or parks; `#conflict!`, `#sole_key`, + `#warn_replaced` and `.accepts_positionals?` are extracted, all for the metric cops. The class + carries one `Metrics/ClassLength` directive with its reason (one snapshot, one class). The + `resolving` slot holds a `Claim` (gate plus owning `Fiber`), as the plan's amendment says. + `assert_core_version!` refuses a non-`String` `core:` (a `Symbol` that stringifies to `~> 0.0` + would otherwise pass). +7. **`Dexpace::Operation`'s checks and composition live in two `private_constant` modules, + `Operation::Validation` and `Operation::Composition`**, so the descriptor's body reads as the + contract and each half is reviewable alone — the split Tasks 13 and 14 draw — and the class + stays under the length cap. `validated_base` carries the fragment rule and, since review + round 2, the no-hierarchical-part rule; `Validation.literal!` holds the brace check and the + path-grammar check on the template's literal text (deviation 28). A template must be a + `String`; a projection must be a two-element `Array`. Both modules are declared in `sig/` and + absent from the manifest. +8. **The version-skew grid varies the running version.** With every gem at `0.0.0` the plan's + grid — twenty-four requirements against the one running version — cannot tell `>=` from `==` + on the minor: `~> 0.0` is the only satisfiable form, and the plan's own break-it step (`>=` to + `==`) stayed green. `registry_version_test.rb` swaps `Dexpace::VERSION` for the duration of a + block through `remove_const`/`const_set` (no "already initialized constant" warning, restored in + an `ensure`, asserted restored) over six running versions, and adds the exhaustive 6 × 12 square + beside the seeded grid. Both breaks then go red. +9. **`Cancellation#on_cancel` builds its per-registration guard through a private `#once_only` + helper and yields to the block**, rather than a `block.call` inside a hand-rolled lambda + (`Performance/RedundantBlockCall`); `yield` inside a lambda that outlives the method reaches the + captured block on 3.2.11 and 4.0.6, verified. `NONE = new([])` — `#initialize` freezes the list + — replaces the plan's `new([].freeze)`, which strict Steep refuses as an unannotated empty + literal. +10. **`.rubocop.yml`'s `Include:` for the cop names `lib/dexpace/serde.rb` as a third + pattern.** The seam module itself reopens `Dexpace::Serde` from one directory up, so a bare + `JSON` there is the same hazard as one under `serde/`; verified the cop fires on it. Broadened + by one file, never narrowed. +11. **Two `Naming/PredicateMethod` directives, each with its reason** — `Cancellation::Source#cancel` + and `Completer#settle` — because the design fixes `#cancel`, `#fulfil`, `#fail` and + `#request_cancel` as commands that report whether they took effect, and a `?` suffix would + misname a mutator as a query; the cop is a `NewCops`-enabled one phase 0 had not met. The + probe scheduler's `#block` carries the same directive for Ruby's own hook name. +12. **The method-level-`else` guard uses a transport that returns `nil`**, not the plan's + `:not_a_token`. With the pre-dispatch check at the top of `#deliver`, `:not_a_token` raises in + the body and is rescued, so the plan's scenario passes under the break; a `nil` response trips + `Settlement`'s "exactly one of response or error" inside `Completer#fulfil`, which is + `SEAM-16`'s null-success rule arriving as a real failure — and the break then leaves the future + unsettled, as the guard says. +13. **`Future#value` guards a `nil` outcome with `Dexpace::SeamError`.** Steep cannot see that + `#wait` returns only once the outcome is written; the branch is unreachable and costs one line. + `Future#then`'s forwarding is two private methods, `#forward` and `#map`, so the rescue sits + around the block call alone. +14. **`bool`, not `boolish`, in `registry.rbs`.** `gates:rbs_surface` reads RBS's built-in + `boolish` alias as a name outside the allowlist; the gate is right that the alias is not a + `Dexpace::` constant, and `bool` is the type anyway. +15. **The two `require_relative` blocks the plan placed differently are one block**, appended + after phase 1's, in Task 15's dependency order (the plan's Task 1 said "immediately after + `error/invalid_argument_error`"; Task 15's order wins). +16. **`Completer#on_cancel` runs the block through `yield` and a `#cancel_reason` helper**, so a + cancelled settlement's reason is read off its `Dexpace::CancelledError` under a type check + rather than off an `Exception?`. +17. **The three seam suites clean a registration out of the private registry in an `ensure`.** + A registration is process-global and kept across `#swap` by design, so a test of the + module-level `register` reaches into `REGISTRY`'s snapshot afterwards to remove its key; an + install is exercised inside a `swap` block instead, because an install is part of the override + and restored with it. +18. **Eager type checks the plan did not have**, in the shape of phase 1's Task 1 rule: + `Future.new` refuses a non-`Completer`, `Completer#fail` a non-`Exception`, `Completer#await` + a non-token, `Cancellation.over`/`.any`/`#merged_with` the wrong class, `Operation` a + non-`String` template and a non-pair projection; each is the SDK's error rather than a + `NoMethodError` from inside the seam. +19. **A second-resolver test** (`registry_test.rb`, `Concurrency`) holds the factory open until a + waiter is known to be parked on the claim's gate, because the 32-thread test cannot promise a + waiter ever parks under the GVL — the gate's wait path had no coverage without it. +20. **`callable?` admits an object whose `respond_to?(:call)` is true but whose `#method(:call)` + raises `NameError`**, asserted; the plan's rescue existed but no test reached it. +21. **`serde/serialization_error_test.rb` and `serde/deserialization_error_test.rb` exist**, so + every `lib/` file but `hooks.rb` has its one-for-one mirror as the design states; the plan + folded both into `serde/error_test.rb`. +22. **`Subscription#detach` guards a `nil` hook**, the `.none` handle's, rather than calling + `off_cancel(nil)` on an empty source list. +23. **A nil query input contributes nothing**, exactly as an unsupplied one; the plan's + `Array(inputs.fetch(key))` already behaved so, and the test says why the path side treats + `nil` differently. +24. **`test/gates/surface_snapshot_test.rb`'s "starts at the namespace it shares" case is + corrected to the property its own comment states.** It asserted that each adapter's manifest + *begins with* the shared namespace line (`Dexpace::Transport`, `Dexpace::Serde`, + `Dexpace::Async`) — true in phase 0 only because core did not define those modules. Core now + does, so an adapter's contribution begins at its own constant inside the namespace and the + namespace line belongs to core's manifest, which is the gate's contract ("what that gem adds + beyond core's"). The test now asserts the first line is *inside* the shared namespace, that + the namespace line is absent, and that `Dexpace` is absent; it goes red against the phase-0 + manifests and green against the regenerated ones. On the code branch, because that branch's + `test:gates` must be green on its own tree. +25. **Run counts exceed the plan's.** `test:gems` is 529 runs where the plan's built-tree run was + 179; Minitest is 5.27.0 on 4.0.6 here, not the 6.0.0 the plan mentions, and assertion counts + match across 3.2.11 and 4.0.6. +26. **`Registry.accepts_positionals?` refuses a required keyword** (review round 1). The design's + stated predicate — "required count ≤ 3 and (a rest parameter is present or required + + optional ≥ 3)" — omits keywords, and the plan reproduced it, so + `->(request, options, cancellation, must:) {}` passed `Transport.conforms?` and + `AsyncTransport.conforms?`, was accepted by `#install` and `#register`, and raised Ruby's + `ArgumentError: missing keyword` from inside the seam at the first send: failure at use where + the predicate exists to fail at registration, and a stdlib error where the seam promises + `Dexpace::InvalidArgumentError`. One clause (`return false if kinds.include?(:keyreq)`) closes + it; an optional keyword, a keyword rest and a block parameter leave the three-positional call + intact and stay admitted. Verified on 3.2.11 and 4.0.6; the design's "as built" notes record + the refinement. `registry_test.rb`'s three `callable?` tests moved into a nested `Callable` + class for the 100-line cap (deviation 4). +27. **A `nil` header input is absent** (review round 1), exactly as deviation 23 made a `nil` + query input, where the plan's `inputs.key?(key)` sent the header with an empty value: a + generated client passing an unset optional header as `nil` would have emitted `X-Trace:` + silently. The path side still makes `nil` an error, because a placeholder cannot be left out, + and an empty `String` is a value on every side. `docs/sdk-documentation/seams.md` states the + three readings together. +28. **A template literal that is not a URI path, and a base with no hierarchical part, are + refused as `Dexpace::InvalidArgumentError`** (review round 2). The design's construction-time + checks were brace balance and placeholder/projection agreement, and its composition row maps + "resolving to a malformed URL" to `URL.parse!` — the base only. The literal text between + placeholders was never checked, so `Operation.build(method: :get, template: "/x?y")` — a + generator putting a literal query in the template — and `"/a b"`, `"/pets/ü"`, `"/100%"` + constructed and then raised `URI::InvalidComponentError` from `URI::Generic#path=` inside + `Composition.compose` at the first `#build_request`: a stdlib error, outside `rescue + Dexpace::Error`, where the plan's global constraints promise `InvalidArgumentError` for a + malformed template and `SEAM-27` a context-bearing error. Path *values* were never at risk + (`encode_component` yields pchars; the 40-sample property proves it). `Validation.literal!` + now checks the template with its placeholders removed against RFC 3986 `path` — every + character a pchar or `/`, every `%` opening a two-hex-digit escape, the grammar `#path=` + enforces at assembly — at construction, naming the template; `/a%20b`, `pets` and `""` pass + as before. The set is spelled out in a per-pattern-timeout `Regexp` rather than borrowed from + `URI`, because phase 1's `URL` is the one place core reaches `URI`. The probe that found the + literal found its sibling: an opaque base (`mailto:x@y`, `urn:isbn:123`) is absolute, so + `URL.parse!` admits it, and carries no fragment, so the one rule `validated_base` had let it + through to leak `URI::InvalidURIError: path conflicts with opaque` from the same `#path=`; + `validated_base` refuses it beside the fragment, naming the base (`#hierarchical?`, which the + rbs stdlib signature declares). Verified identical on 3.2.11 and 4.0.6 before and after; the + red runs are recorded in the tests commit. No rescue was added to `Composition.compose`, on + the claim that with both inputs validated it could not raise — a claim round 3 found false on + the query side (deviation 29), and which now rests on both writers' grammars rather than on + the path side alone. +29. **A base URL whose query is not RFC 3986 is refused as `Dexpace::InvalidArgumentError`** + (review round 3), before anything is composed onto it. Deviation 28 validated the template + literal and the opaque base and then claimed the composition could not raise; the base's + *query* was validated by nothing phase 2 owns. Phase 1's `URL.parse!` reaches + `URI::Generic#query=`, whose percent check is `/(%\H\H)/` — a `%` followed by two *non-hex* + characters — so a query ending in a bare `%` or in `%z` (`https://host/c?sig=100%`, + `https://host/c?;~%`, `https://host/c?a=%z`) is accepted at parse time; `compose_query` then + appends `&limit=1`, `composed.query=` sees `%&l` and `URI::InvalidURIError: invalid percent + escape: %&l` escapes `#build_request` — a stdlib class, outside `rescue Dexpace::Error`, + where `SEAM-27` requires a composition resolving to a malformed URL to be rejected with a + context-bearing error and where deviation 28's round-2 property could not see it, because it + fixes the base at `https://host/c?sig=1`. `Operation::QUERY_LITERAL` is RFC 3986 `query` — + `PATH_LITERAL`'s set plus `?`, every `%` a two-hex escape — and `validated_base` checks the + parsed base's query against it beside the fragment and hierarchical-part rules, naming the + base. The check runs whether or not the operation query is empty, so a base malformed on its + own (`?sig=100%` with no `limit`) is refused rather than composed into a malformed URL + silently; `?sig=100%25` composes as before. The fix is validation and not the belt-and-braces + `rescue ::URI::Error` round 1 offered, for the reason deviation 28 gave and with the argument + it lacked: RFC 3986 `query` is strictly tighter than `#query=`'s check, the operation query is + `Query#encode`'s and RFC 3986 by construction, and the `&` between them can complete no + escape; on the path side the parser's `segment` set is `#path=`'s `ABS_PATH` set, the + operation path is `PATH_LITERAL` literal plus `encode_component` values, and one `/` joins + them — so neither writer can reach its raise, and `Composition.compose`'s comment now says + so. Measured rather than argued as well: a 40,000-base fuzz over nine base shapes (`https`, + `http:` with no authority, `ftp`, `file`, an IPv6 literal) and four operations, on 3.2.11 + and 4.0.6, 29,738 bases accepted by `URL.parse!`, 5,031 stdlib leaks before and 0 after, + identical on both. The 200-sample `Bases` property — the base-varying twin of the `Templates` + one, with a non-empty operation query so the append runs on every sample — pins it. +30. **`Registry#register` and `#install` raise their conflict outside the lock** (review round + 3). Both messages interpolate `#inspect` of two user objects — the incumbent and the rejected + factory or provider — and both were built inside `@write.synchronize`, so a factory or + provider whose `#inspect` reached back into the registry (an install, a registration) met + `ThreadError: deadlock; recursive locking` instead of the documented + `Dexpace::InvalidArgumentError`: the file's own rule that a synchronize body is a snapshot + swap and nothing else, broken on its two conflict paths while `warn_replaced` already obeyed + it. `#register`'s block now returns the incumbent and the raise follows it; `#install`'s swap + moved into a private `#swap_in` that reports the conflicting incumbent and the handed-out + flag out of the block, and `conflict!` takes the incumbent rather than the snapshot. A + class's or a lambda's `#inspect` is trivial, which is why this was filed as a nit; the guard + (`registry_test.rb`, `Reentrancy`) uses an object whose `#inspect` registers a second key. + +## Findings routed + +- **The plan's version grid is vacuous while every gem is at `0.0.0`** — fixed in place + (deviation 8), no register. +- **`Naming/PredicateMethod`, `Performance/RedundantBlockCall`, `Style/OneClassPerFile` and the + metric cops shaped the tree** in ways the plan's fences did not anticipate; each is answered in + the file it bit (deviations 3–7, 9, 11) and none is a finding against a gate. +- **A phase-0 gate test pinned a phase-0 fact** (deviation 24) — repaired on the code branch, + where the layering rule requires it; not a finding against phase 0's gate, whose contract held. +- **`rake rubocop` is vacuous in a worktree nested under the parent checkout's `.claude/`** — + already the thirty-fourth phase-10 inbound bullet, from phase 1; the honest run is recorded above. + +## Postponed work + +The six items phase 2 postponed keep the owners the design's "Work Phase 2 Postponed, and Who Owns +It Now" section records — `close_quietly`'s two routes (phase 4b, Task 2; phase 5b, Task 14), the +`deadline:` keyword (phase 5a, Task 8), the fakes' move (declined by phase 8a), presence-gated +activation (`docs/first-release.md` § What v1 ships without), `Hooks.notify`'s dropped failures +(phase 4b, Task 2) and `SEAM-25`'s lifecycle event (phase 8b, Tasks 6 and 10; phase 9, Task 11) — +and were re-checked on 2026-09-15. The version-skew guard phase 0 postponed to this phase is built +(Tasks 7 and 8). The implementation postponed nothing further. diff --git a/gems/dexpace-core/README.md b/gems/dexpace-core/README.md index 54b309f..e827a60 100644 --- a/gems/dexpace-core/README.md +++ b/gems/dexpace-core/README.md @@ -3,12 +3,17 @@ Part of the [dexpace Ruby SDK](../../README.md): an HTTP-client toolkit, not an HTTP client. This gem is the core: the domain model, the pipeline and every seam's contract. -**Status: `0.0.0`, unpublished; the HTTP domain model is built.** `lib/` holds phase 1's wire -model -- `Dexpace::Request`, `Response`, `Headers`, `HeaderName`, `Status`, `Method`, `Protocol`, -`MediaType`, `Query`, `RequestOptions`, the `HeaderSyntax`, `PercentEncoding` and `URL` function -modules, and the construction contract `Dexpace::Model` / `Dexpace::Builder` under the error root -`Dexpace::Error` -- and nothing else yet: the pipeline, the seams and the body model are later -phases'. The as-built page is `docs/sdk-documentation/http.md`. +**Status: `0.0.0`, unpublished; the HTTP domain model and the seam layer are built.** `lib/` +holds phase 1's wire model -- `Dexpace::Request`, `Response`, `Headers`, `HeaderName`, `Status`, +`Method`, `Protocol`, `MediaType`, `Query`, `RequestOptions`, the `HeaderSyntax`, +`PercentEncoding` and `URL` function modules, and the construction contract `Dexpace::Model` / +`Dexpace::Builder` under the error root `Dexpace::Error` -- and phase 2's seam layer: the provider +registry `Dexpace::Registry`, the `Dexpace::Transport`, `Dexpace::AsyncTransport` and +`Dexpace::Serde` seams, the `Dexpace::Bridge` pair, the async pivot `Dexpace::Async::Future` / +`Completer`, `Dexpace::Cancellation`, `Dexpace::Closeable` and `Dexpace::Operation`. Nothing else +yet: the pipeline, the body model and every adapter are later phases', and no transport ships +here, so nothing talks to a socket. The as-built pages are `docs/sdk-documentation/http.md` and +`docs/sdk-documentation/seams.md`. ## Install @@ -35,6 +40,22 @@ Every type is frozen at construction, validated in its constructor, and derived `#with` or `#new_builder` rather than mutated; a caller mistake raises `Dexpace::InvalidArgumentError`, which is also an `ArgumentError`. +A transport is any object responding to `#call(request, options, cancellation)`, so the seam +layer works end to end with a lambda standing in for the adapter phase 8 ships: + +```ruby +operation = Dexpace::Operation.build( + method: "GET", template: "/pets/{id}", projections: { id: [:path, "id"], limit: [:query, "limit"] }, +) +request = operation.build_request(base_url: "https://api.example.test/v1", inputs: { id: "a/b", limit: 1 }) +request.url.to_s # => "https://api.example.test/v1/pets/a%2Fb?limit=1" + +Dexpace::Transport.swap(->(req, _options, _cancellation) { req.url.to_s }) do + Dexpace::Transport.resolve.call(request, Dexpace::RequestOptions::EMPTY, Dexpace::Cancellation.none) +end +Dexpace::Transport.resolve # raises Dexpace::SeamError: no transport provider is registered +``` + ## Depends on Nothing. `dexpace-core.gemspec` has no `add_dependency` line, and `rake gates:gemspec_audit` asserts @@ -43,6 +64,8 @@ it stays that way (`SEAM-1`, `NFR-1`). ## Where to read next - `docs/sdk-documentation/http.md` -- the domain model as built: what each type guarantees. +- `docs/sdk-documentation/seams.md` -- the seam layer as built: what a transport, a codec and a + future are, and how a provider is resolved. - `docs/sdk-documentation/architecture.md` -- how the gems compose and which one to install. - `docs/sdk-design-ruby/02-gem-and-workspace-layout.md` -- the gem layout and the zero-dependency invariant every gem here is built under. diff --git a/gems/dexpace-core/lib/dexpace.rb b/gems/dexpace-core/lib/dexpace.rb index 385ba7a..04fef0d 100644 --- a/gems/dexpace-core/lib/dexpace.rb +++ b/gems/dexpace-core/lib/dexpace.rb @@ -25,6 +25,28 @@ require_relative "dexpace/http/response" require_relative "dexpace/http/response/builder" +# Phase 2: the seam layer, in dependency order (see the block below for why the order matters). +require_relative "dexpace/error/seam_error" +require_relative "dexpace/error/closed_error" +require_relative "dexpace/error/cancelled_error" +require_relative "dexpace/closeable" +require_relative "dexpace/hooks" +require_relative "dexpace/cancellation" +require_relative "dexpace/cancellation/source" +require_relative "dexpace/async/settlement" +require_relative "dexpace/async/completer" +require_relative "dexpace/async/future" +require_relative "dexpace/registry" +require_relative "dexpace/bridge/async_over" +require_relative "dexpace/bridge/sync_over" +require_relative "dexpace/transport" +require_relative "dexpace/async_transport" +require_relative "dexpace/serde/error" +require_relative "dexpace/serde/serialization_error" +require_relative "dexpace/serde/deserialization_error" +require_relative "dexpace/serde" +require_relative "dexpace/operation" + # 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 diff --git a/gems/dexpace-core/lib/dexpace/async/completer.rb b/gems/dexpace-core/lib/dexpace/async/completer.rb new file mode 100644 index 0000000..59da1d8 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/async/completer.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "settlement" +require_relative "../closeable" +require_relative "../cancellation" +require_relative "../hooks" +require_relative "../error/cancelled_error" +require_relative "../error/invalid_argument_error" + +module Dexpace + module Async + # The write side of the canonical async pivot, and where the pivot's state lives. + # + # Design §10.3: the pivot is core-owned because Ruby's async ecosystem is fragmented across + # Async::Task, Concurrent::Promises::Future, Thread plus Thread::Queue and EventMachine + # descendants, none in the standard library and all with different cancellation semantics. + # Adopting one would put a third-party type in core's public surface, which SEAM-1 and NFR-11 + # forbid. SEAM-17 is a SHOULD naming the pattern, not the type. + # + # A Completer is handed only to the producer, so a consumer cannot settle someone else's + # future. Every constant reached from this namespace is written ::-qualified: once + # dexpace-async-thread is required, a bare `Thread` here is Dexpace::Async::Thread, and core's + # own suite never requires that gem (verified on 3.2.11 and 4.0.6). + # + # #fulfil, #fail and #request_cancel are commands that report whether they took effect, not + # queries: the design fixes those names, and a `?` suffix would misname a mutator. + class Completer + def initialize + @mutex = ::Thread::Mutex.new + @gate = ::Thread::Queue.new + @outcome = nil + @on_settle = [] #: Array[^(Dexpace::Async::Settlement) -> void] + @on_cancel = [] #: Array[^(untyped) -> void] + @future = nil + end + + # @return [Dexpace::Async::Future] the read side, memoised + def future = @future ||= Future.new(self) + + # Whether an outcome has been published. + def settled? = !@outcome.nil? + + # @return [Dexpace::Async::Settlement, nil] + attr_reader :outcome + + # Deliver a response. Returns false when the race was already lost, and closes the response + # it was handed on that path -- SEAM-30, implemented once here so it holds for every adapter + # that settles through a Completer rather than depending on each one remembering. + def fulfil(response) + delivered = settle(Settlement.success(response)) + Dexpace.close_quietly(response) unless delivered + delivered + end + + # Deliver a failure; #value re-raises the identical object, so there is no wrapper to unwrap + # (SEAM-18). + def fail(error) + unless error.is_a?(::Exception) + raise Dexpace::InvalidArgumentError, "fail requires an exception, got #{error.class}" + end + + settle(Settlement.failure(error)) + end + + # Cooperative: this cannot pre-empt a producer, because Ruby's only pre-emption primitives + # are Thread#raise and Thread#kill and design §8.3 forbids them. The producer's obligation is + # check-after-resume (design §3.3), and #on_cancel is how it can abort sooner. + # + # **The outcome is published before the hooks run**, and the hooks are stolen under the same + # lock that publishes it. Written the other way round -- notify, then settle -- one raising + # hook aborts the method and the future is left permanently unsettled, so every #value on it + # blocks forever. Reachable through the public API alone, and reproduced on 3.2.11, 3.4.10 + # and 4.0.6: `completer.on_cancel { raise }` followed by `completer.future.cancel(:stop)` + # left #settled? false. That is the same "the future never settled and #value would block" + # failure Bridge::AsyncOver#deliver's rescue closes, arriving from the other side, so it + # gets the same answer: a cancellation always publishes an outcome. Stealing the hooks in + # the publishing step, rather than before it, also means a producer's abort hook fires only + # when the cancellation actually won the race against its own #fulfil. + def request_cancel(reason = nil) + settle(Settlement.cancellation(Dexpace::CancelledError.new(reason))) + end + + # The producer's hook to abort promptly rather than only at its next resume point. Runs at + # once when the future was already cancelled, and never when it settled any other way. + def on_cancel(&block) + raise Dexpace::InvalidArgumentError, "on_cancel requires a block" unless block + + already = @mutex.synchronize do + if @outcome + @outcome + else + @on_cancel << block + nil + end + end + yield cancel_reason(already) if already&.cancelled + self + end + + # Invoked exactly once with the Settlement, on the settling thread-or-fiber; at once, on the + # calling one, when the future has already settled. + def on_settle(&block) + raise Dexpace::InvalidArgumentError, "on_settle requires a block" unless block + + settled = @mutex.synchronize do + if @outcome + @outcome + else + @on_settle << block + nil + end + end + yield settled if settled + self + end + + # Blocks the calling thread-or-fiber on a Thread::Queue pop, never a spin and never a + # Kernel#sleep poll: under a registered Fiber.scheduler a blocking queue pop routes through + # the scheduler's block/unblock hooks instead of parking the OS thread (verified on 3.2.11, + # 3.4.10 and 4.0.6; Task 6 asserts it). + def await(cancellation = nil) + return self if settled? + + subscription = arm(cancellation) + begin + @gate.pop until settled? + ensure + subscription&.detach + end + self + end + + private + + # The registration is detached in #await's `ensure`, whichever way the wait ends. + # + # It has to be. The hook lives on the caller's token, which a caller may hold for the life of + # a client, and the closure reaches this Completer and through it the response the future + # settled with. `.any(client_token, per_call_token)` with `future.value(cancellation:)` -- + # what phase 5a's deadline: keyword will do on every request -- retained one closure and one + # response per request on the client-lifetime source: measured 200 of 200 on 3.2.11, 3.4.10 + # and 4.0.6, and 500 100 KB responses still reachable after GC.start. Per-call tokens narrow + # the blast radius and do not close it, because the whole point of .any is composing a + # per-call token with a long-lived one. + # + # The reason a cancelled settlement carries; nil for any other settlement. + def cancel_reason(settlement) + error = settlement.error + error.is_a?(Dexpace::CancelledError) ? error.reason : nil + end + + # @return [Dexpace::Cancellation::Subscription, nil] nil when there was nothing to arm + def arm(cancellation) + return nil if cancellation.nil? + + unless cancellation.is_a?(Dexpace::Cancellation) + raise Dexpace::InvalidArgumentError, + "cancellation: takes a Dexpace::Cancellation, got #{cancellation.class}" + end + return nil if cancellation.equal?(Dexpace::Cancellation.none) + + cancellation.on_cancel { |reason| request_cancel(reason) } + end + + # The queue is a wake-up signal and never the value channel: Thread::Queue#pop returns nil + # for a timeout, for a closed queue and for a pushed nil alike (verified on all three + # interpreters), so a pivot that carried its value through the queue could not tell + # "settled with nothing" from "not settled". + # + # The mutex is held across the outcome write and the callback-list steal and across nothing + # else. Callbacks run outside it, so an #on_settle handler that blocks cannot deadlock a + # second fiber of the same thread, and they go through Hooks.notify so that one raising + # handler cannot drop the rest of the list. On a cancellation the producer's abort hooks run + # first and the settle callbacks run whatever the abort hooks did. + def settle(settlement) # rubocop:disable Naming/PredicateMethod -- a command reporting whether it won the race, like the three public commands above it + taken = @mutex.synchronize do + if @outcome + nil + else + @outcome = settlement + both = [@on_settle, @on_cancel] #: stolen + @on_settle = [] + @on_cancel = [] + both + end + end + return false if taken.nil? + + @gate.close + callbacks, abort_hooks = taken + begin + Hooks.notify(abort_hooks, cancel_reason(settlement)) if settlement.cancelled + ensure + Hooks.notify(callbacks, settlement) + end + true + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/async/future.rb b/gems/dexpace-core/lib/dexpace/async/future.rb new file mode 100644 index 0000000..075f5de --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/async/future.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "completer" +require_relative "../error/seam_error" + +module Dexpace + module Async + # The read side of the canonical async pivot: a facade over one Completer, which is where every + # piece of state lives. + # + # .new stays public and type-checks its argument rather than being made private. A second + # facade over the same completer is harmless -- both observe one future -- and the alternative, + # a private constructor the completer reaches through send, would put a send hole in exactly + # the read/write boundary this pair exists to draw. + class Future + def initialize(completer) + unless completer.is_a?(Completer) + raise Dexpace::InvalidArgumentError, + "a future is built over a Dexpace::Async::Completer, got #{completer.class}" + end + + @completer = completer + end + + # Whether an outcome has been published. + def settled? = @completer.settled? + + # Whether the outcome is a cancellation; false while unsettled. + def cancelled? = @completer.outcome&.cancelled ? true : false + + # Settles-or-returns; never raises the failure. + def wait(cancellation: nil) + @completer.await(cancellation) + self + end + + # Blocks, then delivers the response or raises the failure. `deadline:` is deliberately + # absent: SEAM-18's interruption clause is about cancellation, and deadlines need phase 5's + # clock and interruptible-delay primitives (CFG-15..CFG-21; phase 5a, Task 8 adds it). + # Adding the keyword later widens this signature rather than narrowing it, so NFR-4's API + # lock is not prejudiced. + def value(cancellation: nil) + wait(cancellation: cancellation) + settlement = @completer.outcome + # Steep cannot see that #wait only returns once the outcome is written. + raise Dexpace::SeamError, "the future returned from its wait unsettled" if settlement.nil? + + error = settlement.error + raise error unless error.nil? + + settlement.response + end + + # Invoked exactly once with the Settlement, on the settling thread-or-fiber; at once when + # the future has already settled, so a late registration is never lost. + def on_settle(&) + @completer.on_settle(&) + self + end + + # Cooperative cancellation: settles the future promptly and asks the producer to notice + # through Completer#on_cancel. A no-op once settled -- a delivered response is never closed + # (SEAM-16's last clause, ASYNC-20). + def cancel(reason = nil) + @completer.request_cancel(reason) + self + end + + # Derives a future from this one's value, so a caller can adapt a response without blocking a + # thread to do it -- the shape every core consumer of the pivot already hand-rolls out of + # #on_settle plus a second Completer. + # + # Four rules, and none of them is new: the block runs only on a success and the derived + # future settles with what it returns; a failure is forwarded as **the same object**, because + # this port never wraps; a cancellation is forwarded as a *cancellation* rather than as a + # plain failure, so `#cancelled?` stays true one link down and RETRY/XCUT-2 classification + # still reads #reason's class; and cancelling the derived future cancels the one it came + # from, which is the bidirectional half SEAM-18 and ASYNC-6 ask for. Neither direction loops: + # Completer#request_cancel returns false on an already-settled completer. + # + # It settles through Completer#fulfil, so **SEAM-30 applies to a derived future exactly as to + # any other**: a mapped value that loses the completion race is closed through + # Dexpace.close_quietly rather than leaked. That is the whole reason the block's result is + # not written to the outcome directly. + # + # A block that raises fails the derived future instead of escaping the settling thread, and a + # block returning nil fails it through Settlement's "exactly one of response or error" rule + # rather than leaving it unsettled -- both because the call sits inside the rescue. + # + # The name shadows Kernel#then (yield_self) on this class deliberately: on a future the + # promise-combinator reading is the only one a caller means, and leaving Kernel#then + # reachable here would hand back the block's value rather than a Future. + def then(&block) + raise Dexpace::InvalidArgumentError, "then requires a block" unless block + + derived = Completer.new + derived.on_cancel { |reason| cancel(reason) } + on_settle { |settlement| forward(settlement, derived, block) } + derived.future + end + + private + + # The three forwarding rules #then states, in the order a settlement is classified: a + # cancellation, a failure, a success. + def forward(settlement, derived, block) + failure = settlement.error + if failure.nil? + map(settlement.response, derived, block) + elsif settlement.cancelled && failure.is_a?(Dexpace::CancelledError) + derived.request_cancel(failure.reason) + else + derived.fail(failure) + end + end + + # The rescue is what keeps a raising block on the settling thread's side of the boundary. + def map(response, derived, block) + derived.fulfil(block.call(response)) + rescue ::StandardError => error + derived.fail(error) + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/async/settlement.rb b/gems/dexpace-core/lib/dexpace/async/settlement.rb new file mode 100644 index 0000000..b3076e9 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/async/settlement.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../model" +require_relative "../error/invalid_argument_error" + +module Dexpace + # The core-owned async pivot (design §10.3): the canonical, dependency-free future and its + # write side. Every constant reached from this namespace is written ::-qualified, because once + # dexpace-async-thread is required a bare `Thread` here is Dexpace::Async::Thread, and core's + # own suite never requires that gem (verified on 3.2.11 and 4.0.6; Dexpace/QualifiedCoreConstant + # enforces it). + module Async + # What a future settled as. Yielded to #on_settle and returned by Completer#outcome. + # + # The cross-field rule -- exactly one of response or error, and cancelled implying error -- is + # SEAM-16's "MUST NOT complete successfully with a null/absent value" made structural: there is + # no settled-with-nothing state to construct. It lives in #initialize rather than in a builder + # because .build is public API, #with routes every derivation through it, and send(:new, ...) + # reaches the constructor regardless (phase 1's construction rule). + class Settlement < ::Data.define(:response, :error, :cancelled) + include Dexpace::Model + + private_class_method :new + + # The validating factory every construction path goes through. + def self.build(response: nil, error: nil, cancelled: false) + new(response: response, error: error, cancelled: cancelled) + end + + # The three shapes SEAM-16 permits, named so a producer cannot construct a fourth by passing + # the wrong keyword pair to .build. + def self.success(response) = build(response: response) + + # A failure: the error is delivered to the waiter as the same object. + def self.failure(error) = build(error: error) + + # A cancellation: a failure whose error is the Dexpace::CancelledError carrying the reason. + def self.cancellation(error) = build(error: error, cancelled: true) + + def initialize(response:, error:, cancelled:) + if response.nil? == error.nil? + raise Dexpace::InvalidArgumentError, + "a settlement carries exactly one of response or error" + end + if cancelled && error.nil? + raise Dexpace::InvalidArgumentError, "a cancelled settlement carries an error" + end + + super(response: response, error: error, cancelled: cancelled ? true : false) + end + + # The one question #on_settle's block actually asks; computing it from two nil checks at + # every call site is how a caller gets the cross-field rule wrong. + def success? = error.nil? + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/async_transport.rb b/gems/dexpace-core/lib/dexpace/async_transport.rb new file mode 100644 index 0000000..3417b32 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/async_transport.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "registry" +require_relative "async/future" +require_relative "bridge/sync_over" +require_relative "error/seam_error" +require_relative "error/invalid_argument_error" + +module Dexpace + # The asynchronous transport seam: any object responding to + # #call(request, options, cancellation) and returning a Dexpace::Async::Future. + # + # A second top-level constant and a second registry rather than a namespace under + # Dexpace::Transport (deviation P2-1). SEAM-2 enumerates the two transports as two seams, and + # Dexpace::Transport::Async would sit beside Dexpace::Transport::NetHTTP and ::AsyncHTTP -- two + # adapter namespaces -- which is the confusion SEAM-2 exists to prevent. + # + # The seam's return type is the core-owned pivot (design §10.3), never a third-party future, so + # NFR-11's scan over sig/ stays satisfiable; phase 8's adapters bridge to it and never replace it. + module AsyncTransport + REGISTRY = Registry.new( + seam: "async transport", + installer: "Dexpace::AsyncTransport.install", + conforms: ->(object) { Dexpace::AsyncTransport.conforms?(object) }, + ) + private_constant :REGISTRY + + class << self + # The same predicate as the synchronous seam's: the two seams differ only in return type, + # which no runtime check can see before the first call (the gap Dexpace::Transport records). + def conforms?(object) = Dexpace::Registry.callable?(object, arity: 3) + + # Require-time self-registration (design §3.6), with the version-skew guard on `core:`. + def register(key, factory, core:) + REGISTRY.register(key, factory, core: core) + self + end + + # SEAM-5's explicit install, which always wins over a registered factory. + def install(transport) + REGISTRY.install(transport) + self + end + + # The resolved async transport (SEAM-5, SEAM-7). + def resolve = REGISTRY.resolve + + # The keys every required adapter registered under. + def registered_keys = REGISTRY.registered_keys + + # SEAM-6's test-scoped override: block-scoped, unchecked, restored afterwards. + def swap(transport, &) = REGISTRY.swap(transport, &) + + # SEAM-18's async-to-sync bridge. + def sync_over(transport) + unless conforms?(transport) + raise Dexpace::InvalidArgumentError, + "an async transport responds to #call(request, options, cancellation) and " \ + "returns a Dexpace::Async::Future; #{transport.class} does not" + end + + Dexpace::Bridge::SyncOver.new(transport) + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/bridge/async_over.rb b/gems/dexpace-core/lib/dexpace/bridge/async_over.rb new file mode 100644 index 0000000..d7209f9 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/bridge/async_over.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../async/completer" +require_relative "../async/future" +require_relative "../closeable" +require_relative "../error/seam_error" + +module Dexpace + # The two sync<->async bridges SEAM-18 requires, each in its own file. + # + # Not under Dexpace::Transport: deviation P2-1 argues that a seam constant must not sit beside + # the adapter namespaces Dexpace::Transport::NetHTTP and ::AsyncHTTP, and a bridge is no more + # entitled to that seat than a seam is (deviation P2-13). + module Bridge + # A blocking transport presented as an asynchronous one. This is the one place phase 2 itself + # can produce a response no caller will take delivery of, so it is where SEAM-30 is exercised + # rather than merely stated. + # + # Closeable and **not owning**: it holds a caller-supplied transport and a caller-supplied + # executor and creates neither, so close latches and releases nothing. That is SEAM-14's + # ownership clause and XCUT-22's "a caller-supplied client or executor is NEVER closed by the + # SDK", and it is why close does not cascade to the wrapped transport. + class AsyncOver + include Dexpace::Closeable + + def initialize(transport, executor) + @transport = transport + @executor = executor + initialize_closeable(owned: false) + end + + # The future is returned before anything fallible reaches the caller. A raise from + # #post itself -- a shut-down pool, a rejected task -- is routed to the failure channel + # exactly as a raise from the wrapped transport is, because ASYNC-2 and PIPE-30 require one + # normalisation and a caller of an async seam should never have to rescue around #call. + def call(request, options, cancellation) + completer = Dexpace::Async::Completer.new + begin + @executor.post { deliver(completer, request, options, cancellation) } + rescue ::StandardError => error + completer.fail(error) + end + completer.future + end + + private + + # Check-BEFORE-dispatch, then check-after-resume (design §3.3). + # + # The worker's ::Thread::Queue#pop is one of the four suspension points + # concurrency-and-async/611b9392 enumerates, so this block begins executing immediately after + # a resume -- the earliest check-after-resume point a queued task has -- and one test at the + # top turns a wasted round trip into no round trip. Nothing is *broken* without it: ASYNC-3's + # third clause asks only that a queued task not be interrupted, and SEAM-30/ASYNC-5 close the + # orphan through Completer#fulfil's losing-race branch either way. What is lost is one + # connection and, on a non-idempotent method, one server-side side effect a caller believed + # they had cancelled. It belongs to core rather than to the runtime adapter, because + # dexpace-async-thread cannot see the token: the pool posts an opaque block by design + # (concurrency-and-async/08a0e08d), which is also what lets the same object serve + # Dexpace::Page::_Executor. + # + # After the send the state is re-read before acting on the value it produced. If cancelled, + # the response is closed and the future settles through the failure channel rather than + # delivering. Completer#fulfil closes the orphan on a lost race too, so SEAM-30 holds even if + # that branch is missed. + # + # The delivered value is type-checked the way Bridge::SyncOver#call checks the future it is + # handed, and for the mirror-image reason. Both seams' .conforms? is + # Dexpace::Registry.callable?(object, arity: 3) and #parameters cannot see a return type, so + # an ASYNC transport handed to Transport.async_over is accepted at construction. Without this + # branch #deliver hands a Dexpace::Async::Future to Completer#fulfil, whose only validation + # is "exactly one of response or error": the outer Future#value then returns the INNER + # future, nothing raises anywhere, and the real response is never closed because nothing on + # that path knows there is one inside. Phase 4c's Pipeline/AsyncPipeline pair is what makes + # that cheap to hit, and the finding is 4c's, routed here. + # + # The whole body is inside the rescue, deliberately -- the raise above included, so it + # settles the future instead of escaping the posted block. Written with a method-level `else` + # the delivery branch sits OUTSIDE the rescue's protection, so anything raised between the + # send returning and the future settling -- reading #cancelled? off an argument that is not a + # token is the cheapest example -- escapes the posted block, kills the worker under a real + # threaded executor, and leaves the future permanently unsettled with every #value blocked + # on it. Reproduced on 3.2.11, 3.4.10 and 4.0.6. + def deliver(completer, request, options, cancellation) + return completer.request_cancel(cancellation.reason) if cancellation&.cancelled? + + response = @transport.call(request, options, cancellation) + if response.is_a?(Dexpace::Async::Future) + raise Dexpace::SeamError, + "Transport.async_over wraps a SYNCHRONOUS transport; the one it was given " \ + "delivered a #{response.class}. Call that transport directly, or present it " \ + "through Dexpace::AsyncTransport.sync_over first." + end + + if cancellation&.cancelled? + Dexpace.close_quietly(response) + completer.request_cancel(cancellation.reason) + else + completer.fulfil(response) + end + rescue ::StandardError => error + completer.fail(error) + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/bridge/sync_over.rb b/gems/dexpace-core/lib/dexpace/bridge/sync_over.rb new file mode 100644 index 0000000..63f61df --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/bridge/sync_over.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../async/future" +require_relative "../closeable" +require_relative "../error/seam_error" + +module Dexpace + module Bridge + # An asynchronous transport presented as a blocking one. + # + # SEAM-18's three clauses. *Unwrap the async-wrapper exception*: this port never wraps -- + # Completer#fail stores the error and Future#value re-raises that object -- so the clause holds + # structurally and the test asserts object identity rather than an equal message. *Honour + # interruption*: the wait takes the cancellation token, and on cancellation it cancels the + # in-flight future and raises Dexpace::CancelledError. "Restore the interrupt flag" is vacuous + # for exactly the reason ASYNC-4 is (design §10.5): a port that never delivers an interrupt + # cannot leave a stale one set. "Surface an interrupted-I/O error" is read as a typed + # cancellation error rather than an IOError, because XCUT-4's I/O family is for transport + # failures and a cancellation is not one (deviation P2-4). *Options are threaded*: they are + # passed through unchanged, and a test asserts the exact object arrives. + # + # Closeable and not owning, for the same reason AsyncOver is: the wrapped transport is the + # caller's (SEAM-14, XCUT-22). + class SyncOver + include Dexpace::Closeable + + def initialize(transport) + @transport = transport + initialize_closeable(owned: false) + end + + # The blocking send: dispatches, then waits on the future under the caller's token. + def call(request, options, cancellation) + future = @transport.call(request, options, cancellation) + unless future.is_a?(Dexpace::Async::Future) + raise Dexpace::SeamError, + "an async transport must return a Dexpace::Async::Future, got #{future.class}" + end + + future.value(cancellation: cancellation) + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/cancellation.rb b/gems/dexpace-core/lib/dexpace/cancellation.rb new file mode 100644 index 0000000..19b6a34 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/cancellation.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "error/cancelled_error" +require_relative "error/invalid_argument_error" + +module Dexpace + # A cancellation token: the third argument of both transport seams, the state SEAM-13 asks a + # blocking transport to honour, and the trigger for SEAM-30's orphaned-response close. + # + # The reason is a typed object and never a message string, because XCUT-2 requires a timeout and + # a cancellation to be told apart by ambient state "even when the runtime represents both with + # the same exception type" -- which matters in Ruby, where Net::ReadTimeout is distinguishable + # but Errno::* and IOError are not reliably. Phase 6 classifies on #reason's class. + # + # A token is a facade over a frozen list of Sources; the mutable state is theirs. That makes + # .any a concatenation of source lists rather than a subscription graph, and makes .none a + # singleton over the empty list that can never be cancelled and allocates nothing per call. + # + # What is deliberately absent: deadline-derived tokens and the clock behind them. Those are + # CFG-15..CFG-21 and phase 5a's (Task 8); .any is the composition point they will use. + class Cancellation + private_class_method :new + + # The handle #on_cancel returns. #detach removes that one registration from every source the + # token observes. + # + # A handle rather than `self`, because a registration that cannot be withdrawn is a leak with + # a polite name: Completer#await registers one per wait on a token the caller may hold for the + # life of a client, and the closure reaches the Completer and therefore the response it + # settled with. #detach is idempotent, and a no-op on a source that has already cancelled and + # cleared its list. + class Subscription + # Built by Cancellation#on_cancel and by nothing else. + def initialize(sources, hook) + @sources = sources + @hook = hook + end + + # @return [nil] always, so a caller cannot branch on a detach outcome + def detach + hook = @hook + @sources.each { |source| source.off_cancel(hook) } unless hook.nil? + nil + end + end + + # @return [Cancellation] the shared, frozen token that is never cancelled + def self.none = NONE + + # @return [Cancellation::Source] a fresh write side, with its token + def self.source = Source.new + + # Builds a token over sources directly. Public because phase 5's deadline source composes here + # and because #merged_with needs a class-level constructor that is not `new`. + # + # Public API validates its arguments. Without this guard `Cancellation.over(:nope)` builds a + # token that raises NoMethodError from inside #cancelled? at some later point, while .any and + # #merged_with -- the two other composition entry points -- both name the offending class at + # the call that made the mistake. + # + # @raise [Dexpace::InvalidArgumentError] when handed anything but a Source + def self.over(*sources) + sources.each do |source| + next if source.is_a?(Source) + + raise Dexpace::InvalidArgumentError, + "Cancellation.over accepts Dexpace::Cancellation::Source objects, " \ + "got #{source.class}" + end + return NONE if sources.empty? + + new(sources) + end + + # A token cancelled when any of the inputs is. + # + # @raise [Dexpace::InvalidArgumentError] when handed anything but a token + def self.any(*tokens) + tokens.each do |token| + next if token.is_a?(Cancellation) + + raise Dexpace::InvalidArgumentError, + "Cancellation.any accepts Dexpace::Cancellation tokens, got #{token.class}" + end + return NONE if tokens.empty? + + tokens.fetch(0).merged_with(*tokens.drop(1)) + end + + # A token is a frozen list of sources and holds no state of its own: #cancelled? and #reason + # are computed from the sources on every call. + # + # The winner is the cancelled source with the earliest #cancelled_at -- a monotonic nanosecond + # stamp each Source takes when it cancels -- not the first in list order. Reading the reason + # off the first cancelled source in list order would make #reason disagree with the reason + # #on_cancel was just handed for the whole life of the token, which is the one thing a composed + # token must not do. + # + # Ordering by that stamp rather than by a subscription is what lets a token subscribe to + # NOTHING at construction. The alternative -- subscribe to every source and latch the winner -- + # is also correct and leaks: a token retains one closure on every source for as long as that + # source lives, and composing a client-lifetime token with a per-call deadline token, which is + # what .any is for and what phase 5a's deadline: keyword will do on every request, retained 201 + # closures on one source over 200 compositions (measured on 3.2.11 and 4.0.6). + # + # What the stamp buys is convergence, not atomicity, and the residual is stated rather than + # claimed away. A Source takes its stamp BEFORE it takes its own mutex, so a source with the + # earlier stamp can publish its state after a handler has already fired on a later-stamped one, + # and #reason then flips to the earlier one. Demonstrated deterministically on 3.2.11, 3.4.10 + # and 4.0.6 by holding one source's mutex across the other's #cancel, and observed in a + # free-running race 2 times in 120,000 on 3.2.11. No stamp placement closes it: the two sources + # hold two different mutexes and there is no order between them, so moving the read inside the + # lock narrows the window without removing it. What holds unconditionally is what a caller may + # rely on -- the token is cancelled, every reason it ever reports belongs to a source that + # really was cancelled, and the value converges once every racing source has published. + # + # Ties are not the problem and are not treated as one: 0 same-nanosecond collisions in 100,000 + # stamps, with a 50 ns median gap between successive CLOCK_MONOTONIC reads at 1 ns resolution, + # measured on all three interpreters. Two sources stamped inside one nanosecond tie-break on + # list order. + def initialize(sources) + @sources = sources.freeze + freeze + end + + # Composition is an instance method so that #sources can stay protected: a class method has the + # class as `self` and cannot call a protected instance method, which is what forces #sources + # public if `.any` does the work itself. + def merged_with(*others) + others.each do |other| + next if other.is_a?(Cancellation) + + raise Dexpace::InvalidArgumentError, + "merged_with accepts Dexpace::Cancellation tokens, got #{other.class}" + end + combined = others.each_with_object(sources.dup) { |other, all| all.concat(other.sources) } + Cancellation.over(*combined.uniq) + end + + # Whether any observed source has cancelled. + def cancelled? = @sources.any?(&:cancelled?) + + # The reason of the source that cancelled first in time. A deliberate `nil` reason is still a + # cancellation, because #cancelled? is the question and this is not. + def reason = winner&.reason + + # The check-after-resume primitive: a producer calls this after any operation that may have + # suspended and before acting on the value it produced (design §3.3). + # + # @raise [Dexpace::CancelledError] carrying #reason + def check! + raise Dexpace::CancelledError, reason if cancelled? + + nil + end + + # Each registered block is invoked **exactly once**, whether the token observes one source or + # several, and the guard is a flag private to that registration. A flag shared across the token + # would silently drop every registration after the first, which is what makes a second waiter + # on one token block forever -- a SEAM-18 violation no single-waiter test can see. + # + # The guard mutex is held across the flag flip and across nothing else; the block runs outside + # it, so a handler that blocks cannot deadlock a second fiber of one thread. The handler is + # handed #reason rather than the firing source's own, so a handler and a later #reason read + # agree in every ordering the handler itself can observe; #initialize states the window across + # which they can still disagree, and why no stamp placement closes it. + # + # Subscribing happens here and only here: a token nobody registers a callback on subscribes to + # nothing, so composition costs nothing that outlives the token. + # + # @return [Dexpace::Cancellation::Subscription] the handle that detaches this registration. + # Returning a handle rather than `self` is what makes a bounded subscription possible at all. + # Composition subscribing to nothing is only half the leak: the other half is a registration + # that outlives what it was made for. `.any(client_token, per_call_token)` with + # `future.value(cancellation:)` -- what phase 5a's deadline: keyword does on every request -- + # retained one closure, and through it one response, per request on the client-lifetime + # source: measured 200 of 200 on all three interpreters before #await detached. A caller + # registering for the life of the token ignores the value. + def on_cancel(&block) + raise Dexpace::InvalidArgumentError, "on_cancel requires a block" unless block + return Subscription.new(@sources, nil) if @sources.empty? + + once = once_only { yield reason } + @sources.each { |source| source.on_cancel(&once) } + Subscription.new(@sources, once) + end + + protected + + # Protected, not public: only another token composes on it, and that is #merged_with. + attr_reader :sources + + private + + # The per-registration guard: a lambda that runs the block on its first call and never again. + # The guard mutex is held across the flag flip and across nothing else. + def once_only + fired = false + guard = ::Thread::Mutex.new + lambda do |_source_reason| + run = guard.synchronize do + if fired + false + else + fired = true + end + end + yield if run + end + end + + def winner = @sources.select(&:cancelled?).min_by { |source| source.cancelled_at || 0 } + + NONE = new([]) + private_constant :NONE + end +end diff --git a/gems/dexpace-core/lib/dexpace/cancellation/source.rb b/gems/dexpace-core/lib/dexpace/cancellation/source.rb new file mode 100644 index 0000000..0d6a768 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/cancellation/source.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../cancellation" +require_relative "../hooks" +require_relative "../error/invalid_argument_error" + +module Dexpace + class Cancellation + # The write side of a cancellation, handed only to whoever is entitled to cancel -- the Ruby + # answer to a language with no way to hide a mutator. A consumer holding the token cannot + # cancel someone else's operation. + # + # State is one frozen Data snapshot swapped under a Thread::Mutex held across the swap and + # nothing else; the hooks run outside it. The snapshot is a private_constant, so it does not + # include Dexpace::Model and has no .build: phase 1's construction rule is about a public + # constructor, and this has none (design P2-9). + class Source + State = ::Data.define(:cancelled, :reason, :cancelled_at) + private_constant :State + + def initialize + @mutex = ::Thread::Mutex.new + @state = State.new(cancelled: false, reason: nil, cancelled_at: nil) + @hooks = [] #: Array[^(untyped) -> void] + @token = Cancellation.over(self) + end + + # @return [Dexpace::Cancellation] the read side to hand to a transport + attr_reader :token + + # Whether #cancel has been called. + def cancelled? = @state.cancelled + + # The reason the first #cancel supplied, or nil. + def reason = @state.reason + + # The monotonic nanosecond stamp taken when this source was cancelled, or nil. Public because + # a composed token orders its sources by it to find the one that cancelled first in time, + # which is what lets a token subscribe to nothing at construction. + def cancelled_at = @state.cancelled_at + + # The state is published inside the lock and the hooks run outside it, so a handler that + # raises can never leave the source uncancelled. The notification goes through Hooks.notify + # rather than a bare `hooks.each`: one raising handler in a bare each drops every + # later-registered handler, which is the "a second waiter on one token blocks forever" + # SEAM-18 failure arriving from the write side -- verified on all three interpreters. + # + # @return [Boolean] true for the call that actually cancelled, false for every later one + def cancel(reason = nil) # rubocop:disable Naming/PredicateMethod -- a command reporting whether it took effect, not a query; the design fixes the name + at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) + hooks = @mutex.synchronize do + if @state.cancelled + nil + else + @state = State.new(cancelled: true, reason: reason, cancelled_at: at) + taken = @hooks + @hooks = [] + taken + end + end + return false if hooks.nil? + + Hooks.notify(hooks, reason) + true + end + + # Registers a hook, or runs it immediately when the source has already cancelled. + # + # @return [self] + def on_cancel(&block) + raise Dexpace::InvalidArgumentError, "on_cancel requires a block" unless block + + settled = @mutex.synchronize do + if @state.cancelled + @state + else + @hooks << block + nil + end + end + yield settled.reason if settled + self + end + + # Withdraws a hook a token registered, so a bounded wait retains nothing on an unbounded + # source. Called by Cancellation::Subscription#detach and by nothing else. Idempotent, and a + # no-op once #cancel has taken the list. Array#delete compares with ==, which for a Proc is + # object identity, so one registration is removed and an identical-looking one is not. + # + # @return [self] + def off_cancel(hook) + @mutex.synchronize { @hooks.delete(hook) } + self + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/closeable.rb b/gems/dexpace-core/lib/dexpace/closeable.rb new file mode 100644 index 0000000..e6862e4 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/closeable.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "error/seam_error" + +# Dexpace::Closeable and Dexpace.close_quietly live in one file: the helper is the contract's +# discard-path exit and has no meaning apart from it (module-organization/1828a984 counts +# constants, and this file defines one). +module Dexpace + # The close contract, written down once because six requirements state it in six vocabularies -- + # SEAM-14, SEAM-25, HTTP-43, ASYNC-15..ASYNC-17, XCUT-13 and XCUT-22 -- and Ruby has no Closeable + # interface and no try-with-resources to infer it from. + # + # Three properties, and each is here rather than in each implementation because each was + # implemented differently at three sites in the reference build. (1) Close is idempotent, latched + # rather than flag-checked: whoever flips the latch runs #release and everyone else returns. + # (2) Ownership is a construction-time fact, not a close-time judgement -- a component takes a + # resource through an entry point that either builds it or borrows it, and records which in a + # frozen boolean. (3) Close never blocks on an interrupt-sensitive wait, which is free here + # because this port delivers no interrupts (design §8.3). + # + # The latch mutex is created in #initialize_closeable rather than lazily, because a lazily + # created mutex is itself the race it exists to prevent. + module Closeable + # Call from the including class's #initialize. `owned:` is SEAM-14's and XCUT-22's distinction: + # false means the caller supplied the resource and the SDK must never release it. + def initialize_closeable(owned: true) + @dexpace_owned = owned ? true : false + @dexpace_closed = false + @dexpace_close_mutex = ::Thread::Mutex.new + nil + end + + # Whether #close will run #release: true for a resource this component built, false for one + # the caller supplied. + def owned? + ensure_closeable_initialized + @dexpace_owned + end + + # Whether the latch has flipped, whatever ownership says: a borrowing wrapper is closed once + # #close has been called even though the resource it borrowed is not. + def closed? + ensure_closeable_initialized + @dexpace_closed + end + + # Idempotent and ownership-aware. The mutex is held across the flag flip and nothing else: + # Ruby's Mutex is per-fiber-owned and non-reentrant, so holding it across a #release that may + # suspend deadlocks two fibers of one thread. A #release that raises still leaves the latch + # flipped, so no second release is attempted and the failure propagates exactly once. + def close + ensure_closeable_initialized + first = @dexpace_close_mutex.synchronize do + if @dexpace_closed + false + else + @dexpace_closed = true + end + end + return nil unless first + return nil unless @dexpace_owned + + release + nil + end + + private + + # NotImplementedError is a ScriptError and not a StandardError, deliberately: an owning class + # that forgot #release is a programmer error and must not be swallowed by an ordinary rescue + # -- or by Dexpace.close_quietly's. + def release + raise NotImplementedError, + "#{self.class} includes Dexpace::Closeable and must define a private #release" + end + + def ensure_closeable_initialized + return if defined?(@dexpace_close_mutex) && @dexpace_close_mutex + + raise Dexpace::SeamError, + "#{self.class} includes Dexpace::Closeable but never called #initialize_closeable" + end + end + + # The single sanctioned exit for a close on a cleanup or discard path: null-safe (CFG-21's last + # clause), tolerant of an object with no #close, and never raising over a primary failure. + # + # Design §3.7 gives the rescued error two disposal routes and phase 2 has neither: the suppressed + # trail is Dexpace::Error#suppressed, postponed to phase 4b (Task 1), and the + # http.instrumentation.* diagnostic is §8.1's facade, phase 5. Building either here would fix an + # interface a later phase must be free to shape, so the error is dropped; phase 4b (Task 2) + # supplies the first route and phase 5b (Task 14) the second. The two loud exceptions §3.7 + # names are honoured from the start and do not come through here: an explicit #close by a + # caller propagates, and a #release raising during the latched close above propagates once. + # Only StandardError is rescued: a NotImplementedError from a class that forgot #release is a + # programmer error, not a close failure. + # + # @return [nil] always, so a caller cannot branch on a cleanup outcome + def self.close_quietly(resource) + return nil if resource.nil? + return nil unless resource.respond_to?(:close) + + begin + resource.close + rescue ::StandardError + nil # dropped until phase 4b, Task 2 and phase 5b, Task 14 supply the two routes + end + nil + end +end diff --git a/gems/dexpace-core/lib/dexpace/error/cancelled_error.rb b/gems/dexpace-core/lib/dexpace/error/cancelled_error.rb new file mode 100644 index 0000000..662ac60 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/error/cancelled_error.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../error" + +module Dexpace + # Raised when a cancelled operation is observed: Cancellation#check!, Future#value on a cancelled + # future, and the blocking half of SEAM-18's bridge. + # + # The reason is carried as an object rather than folded into the message because XCUT-2 requires + # a timeout and a cancellation to be told apart by ambient state, never by matching a string. + # Phase 6's RETRY-23/RETRY-24 classification reads #reason, not #message. It stays outside + # Ruby's IOError family deliberately: XCUT-4's I/O family is for transport failures, and a + # cancellation is not one (design P2-4). + class CancelledError < ::StandardError + include Dexpace::Error + + # @return [Object, nil] whatever the canceller supplied, untouched + attr_reader :reason + + # The one-argument shape is what `raise Dexpace::CancelledError, reason` needs: Ruby hands + # the second argument of `raise` to the class's constructor, so the reason travels as itself. + def initialize(reason = nil) + @reason = reason + super(reason.nil? ? "the operation was cancelled" : "the operation was cancelled: #{reason}") + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/error/closed_error.rb b/gems/dexpace-core/lib/dexpace/error/closed_error.rb new file mode 100644 index 0000000..9b86a60 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/error/closed_error.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../error" + +module Dexpace + # Raised by a seam implementation when it is used after #close. + # + # SEAM-15 is a MAY -- "further send calls MAY have undefined behavior ... A port MAY choose a + # mode but SHOULD document it" -- and this port chooses and documents one, because "undefined" + # in Ruby means whatever NoMethodError the internals happen to produce. The documented mode is + # narrower than "a send after close raises": a transport that OWNS the resource it closed + # raises this from a later send, while a wrapper that only borrows (both SEAM-18 bridges) + # closes nothing and stays usable, which is what XCUT-22's "the caller owns its lifecycle" + # requires. Phase 2 ships the class and the rule and no raise site; phase 8's adapters are the + # first owners and dexpace-conformance asserts the raise per adapter. + class ClosedError < ::StandardError + include Dexpace::Error + end +end diff --git a/gems/dexpace-core/lib/dexpace/error/seam_error.rb b/gems/dexpace-core/lib/dexpace/error/seam_error.rb new file mode 100644 index 0000000..176e785 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/error/seam_error.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../error" + +module Dexpace + # Raised when a seam is in a state the caller has to fix but did not pass in: no provider is + # registered, more than one is (SEAM-5), or an adapter was built against an incompatible + # dexpace-core (design §2.3's version-skew guard). + # + # Deliberately not an ArgumentError. `error-handling/5a185ba9` asks for a standard-library + # exception where one exactly fits, and ArgumentError exactly fits a bad argument -- which is + # what Dexpace::InvalidArgumentError is for, and is what a conflicting install raises. Nothing + # was wrong with the argument here; the process is missing a provider. + class SeamError < ::StandardError + include Dexpace::Error + end +end diff --git a/gems/dexpace-core/lib/dexpace/hooks.rb b/gems/dexpace-core/lib/dexpace/hooks.rb new file mode 100644 index 0000000..5ee4c8e --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/hooks.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +module Dexpace + # Running a list of caller-supplied callbacks, in one place, once. + # + # Three sites take the same shape -- Cancellation::Source#cancel, Async::Completer#settle and + # Async::Completer#request_cancel -- and each publishes state under its mutex and then notifies + # outside it. Written as a bare `hooks.each { |hook| hook.call(...) }` at each site, ONE raising + # handler drops every later-registered handler and propagates out to whoever published the + # state. That is the "a second waiter on one token blocks forever" SEAM-18 failure arriving from + # the write side: the second waiter's handler is simply never called. Verified on 3.2.11, 3.4.10 + # and 4.0.6. + # + # So every hook runs, whatever an earlier one did, and the FIRST failure is re-raised once the + # whole list has run. Re-raised rather than dropped, because phase 2 has neither disposal route + # design §3.7 names -- Dexpace::Error#suppressed is phase 4b's (Task 1) and the + # http.instrumentation.* diagnostic is phase 5's (§8.1) -- and a handler that raises into a void + # is a bug nothing reports. Re-raising is safe here in a way it is not at the naive site: the + # state is already published and every other handler has already run, so the raise can no longer + # leave a token uncancelled or a future unsettled. The failures after the first are dropped until + # #suppressed exists to carry them; phase 4b, Task 2 attaches them. + # + # Only StandardError is collected. A ScriptError, a NoMemoryError or a SignalException raised by + # a handler is not a handler bug to be gathered up and re-raised later. + module Hooks + # @param hooks [Array<#call>] the list the caller already stole from under its own lock + # @param argument [Object] the single argument every hook is called with + # @return [nil] + def self.notify(hooks, argument) + failure = nil #: StandardError? + hooks.each do |hook| + hook.call(argument) + rescue ::StandardError => error + failure ||= error + end + raise failure if failure + + nil + end + end + + # Not public API: it is a shape three internal call sites share, not a service core offers. A + # private_constant is still reachable by the unqualified name from anywhere lexically inside + # `module Dexpace`, which is where all three call sites are -- verified on 3.2.11, 3.4.10 and + # 4.0.6 -- and unreachable as `Dexpace::Hooks` from outside. + private_constant :Hooks +end diff --git a/gems/dexpace-core/lib/dexpace/operation.rb b/gems/dexpace-core/lib/dexpace/operation.rb new file mode 100644 index 0000000..8d33424 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/operation.rb @@ -0,0 +1,371 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "model" +require_relative "http/method" +require_relative "http/url" +require_relative "http/query" +require_relative "http/query/builder" +require_relative "http/headers" +require_relative "http/headers/builder" +require_relative "http/request" +require_relative "http/request/builder" +require_relative "http/percent_encoding" +require_relative "error/invalid_argument_error" + +module Dexpace + # The operation-input projection seam: a frozen descriptor plus one builder method. + # + # SEAM-26 requires a per-operation declaration of an HTTP method, a path template with named + # placeholders, and typed projections of inputs onto path / query / header / body, "so operation + # arguments flow through typed projections rather than URL string surgery". No code generation is + # implied; this is the runtime primitive a generator would target. + # + # `projections` maps an input key to [:path | :query | :header | :body, wire_name]. Only method + # and template are required and projections default to empty, so a parameterless GET is the + # default construction. + # + # SEAM-28's stable operation identifier is a MAY and is deferred (phase 5c, Task 4): both of + # its halves need machinery phase 2 does not have -- the request's context chain (CTX, phase 4) + # and a consumer for the identifier (instrumentation, phase 5). + # + # The member IS named `method`: SEAM-26 fixes it by name, and the shadowing of Object#method is + # the same deliberate choice phase 1's Request makes, so the lint's "may be unexpected" is + # answered rather than accepted. + class Operation < ::Data.define(:method, :template, :projections) # rubocop:disable Lint/DataDefineOverride + include Dexpace::Model + + TARGETS = %i[path query header body].freeze + private_constant :TARGETS + + # Per-pattern timeouts, never the process-global Regexp.timeout: a library must not impose a + # regexp budget on its host. + PLACEHOLDER = Regexp.new("\\{([^{}]*)\\}", timeout: 1.0) + private_constant :PLACEHOLDER + + BRACE = Regexp.new("[{}]", timeout: 1.0) + private_constant :BRACE + + # RFC 3986 `path` with the placeholders removed: every character a pchar -- unreserved, + # sub-delims, ":" or "@" -- or "/", and every "%" opening a two-hex-digit escape. The same + # grammar URI::Generic#path= enforces at assembly, checked here so the failure is the SDK's own + # argument error at construction rather than a stdlib URI::InvalidComponentError at the first + # #build_request (SEAM-27's "resolving to a malformed URL is rejected with a context-bearing + # error"). The set is spelled out rather than borrowed from URI: phase 1's URL is the one + # place core reaches URI, and rbs declares its parser as an empty class. + PATH_LITERAL = Regexp.new("\\A(?:%\\h\\h|[A-Za-z0-9\\-._~!$&'()*+,;=:@/])*\\z", timeout: 1.0) + private_constant :PATH_LITERAL + + # RFC 3986 `query`: PATH_LITERAL's set plus "?". Phase 1's URL.parse! reaches + # URI::Generic#query=, whose percent check is /(%\H\H)/ -- a "%" followed by two NON-hex + # characters -- so a base query ending in a bare "%" or in "%z" is accepted at parse time; + # the composition's "&limit=1" then puts "%&l" in front of the same check, from #query= again, + # and a stdlib URI::InvalidURIError escapes #build_request. RFC 3986 `query` is strictly + # tighter than that check (every "%" opens a two-hex escape, so no append can complete a + # "%\H\H"), and the operation query is Query#encode's, which is RFC 3986 by construction; a + # base query that passes here therefore composes without reaching the writer's raise. The + # check runs before anything is appended, so the failure is the SDK's own argument error + # naming the base, and a base malformed on its own is refused rather than composed into a + # malformed URL silently when the operation query happens to be empty. + QUERY_LITERAL = Regexp.new("\\A(?:%\\h\\h|[A-Za-z0-9\\-._~!$&'()*+,;=:@/?])*\\z", timeout: 1.0) + private_constant :QUERY_LITERAL + + private_class_method :new + + # The validating factory every construction path goes through. + def self.build(method:, template:, projections: {}) + new(method: method, template: template, projections: projections) + end + + # The placeholder names in `text`, in order. Public because a generator validating a template + # before building an Operation needs the same answer #initialize's check runs on. + # + # @return [Array] + def self.placeholders_in(text) = text.to_s.scan(PLACEHOLDER).flatten + + # Validation lives here rather than in a builder, because .build is public API, #with routes + # every derivation through it, and send(:new, ...) reaches the constructor regardless. A + # validating constructor also coerces: `method` goes through Dexpace::Method.of so a String + # never survives as a member. + def initialize(method:, template:, projections:) + resolved = Dexpace::Method.of(Dexpace::Model.required!("method", method)) + text = Dexpace::Model.required!("template", template) + raise Dexpace::InvalidArgumentError, "template must be a String" unless text.is_a?(::String) + + table = Validation.projections(Dexpace::Model.required!("projections", projections)) + Validation.template!(text, table) + super( + method: resolved, + template: Dexpace::Model.frozen_string(text), + projections: Dexpace::Model.own(table), + ) + end + + # The template's placeholder names, in order. + def placeholders = self.class.placeholders_in(template) + + # Assembles a Dexpace::Request against a base URL. + # + # @param base_url [String, URI::Generic] parsed through phase 1's Dexpace::URL.parse!, which + # pins URI::RFC3986_PARSER and rejects a non-absolute or malformed URL with the offending + # input in the message (HTTP-47) + # @param inputs [Hash] operation arguments, keyed as the projection table is + # @raise [Dexpace::InvalidArgumentError] a base carrying a fragment, no hierarchical part or + # a query that is not RFC 3986, or a projected path input with no value + def build_request(base_url:, inputs: {}) + base = validated_base(base_url) + builder = Dexpace::Request.builder + builder.method = method + builder.url = Composition.compose(base, render_path(inputs), render_query(inputs)) + builder.headers = render_headers(inputs) + builder.body = body_input(inputs) + builder.build + end + + private + + # Phase 1's URL.parse! rejects the malformed and non-absolute case naming the input; the + # three rules it does not carry are SEAM-27's. A base carrying a fragment is refused, naming + # the base. So is one with no hierarchical part -- "mailto:x@y", "urn:isbn:123" -- because + # there is no path to compose onto: URI::Generic#path= on such a base raises a stdlib + # "path conflicts with opaque", which is the leak this method exists to convert. And so is + # one whose query is not RFC 3986 -- "https://host/c?sig=100%" -- because #query= admits it + # at parse time and refuses it once the operation query is appended (QUERY_LITERAL's note). + def validated_base(base_url) + base = Dexpace::URL.parse!(base_url) + unless base.hierarchical? + raise Dexpace::InvalidArgumentError, + "a base URL must have a hierarchical part to compose onto: " \ + "#{Dexpace::URL.external_form(base)}" + end + unless base.fragment.nil? + raise Dexpace::InvalidArgumentError, + "a base URL must not carry a fragment: #{Dexpace::URL.external_form(base)}" + end + return base if QUERY_LITERAL.match?(base.query.to_s) + + raise Dexpace::InvalidArgumentError, + "a base URL's query must be RFC 3986 -- every character a pchar, \"/\" or \"?\", " \ + "and every \"%\" opening a two-digit escape: #{Dexpace::URL.external_form(base)}" + end + + # Each value goes through phase 1's strict RFC 3986 component encoder, whose unreserved set is + # exactly A-Za-z0-9-._~, so a value containing "/" becomes "%2F" and cannot inject a segment. + # That is SEAM-27's whole security property, and it is one call to a function phase 1 already + # verified byte for byte. + def render_path(inputs) + encoded = {} #: Hash[String, String] + projections.each do |key, (target, name)| + next unless target == :path + + value = inputs[key] + # `nil` is missing, not empty. SEAM-27 makes a placeholder with no supplied value an + # error, and `inputs.key?` alone would let { id: nil } render "/pets/" -- a URL that is + # well-formed, wrong, and silent. `false` is a value and survives. + if value.nil? + raise Dexpace::InvalidArgumentError, + "operation input #{key.inspect} is required for path placeholder #{name.inspect}" + end + + encoded[name] = Dexpace::PercentEncoding.encode_component(value.to_s) + end + template.gsub(PLACEHOLDER) { encoded.fetch(::Regexp.last_match(1).to_s) } + end + + # SEAM-27's "the query MUST be RFC-3986 rendered" is phase 1's Query#encode, and a repeated + # projection emits one parameter per value because Query is a pair list, not a Hash. + def render_query(inputs) + builder = Dexpace::Query.builder + projections.each do |key, (target, name)| + next unless target == :query + next unless inputs.key?(key) + + Array(inputs.fetch(key)).each { |value| builder.add(name, value) } + end + builder.build.encode + end + + # The outbound direction, so HTTP-17/HTTP-18 validation happens here rather than at the + # transport -- a header value carrying a CRLF is rejected while the request is being assembled. + # + # A nil input is absent, as it is for a query projection and unlike a path placeholder: a + # header can be left out where a placeholder cannot, and a generated client passing an unset + # optional header as nil must not send an empty one. An empty String is a value and goes out. + def render_headers(inputs) + builder = Dexpace::Headers.builder + projections.each do |key, (target, name)| + next unless target == :header + + value = inputs[key] + builder.add(name, value.to_s) unless value.nil? + end + builder.build + end + + # SEAM-26's "the body is carried, not encoded": the input is handed to the request untouched. + def body_input(inputs) + found = projections.find { |_, (target, _)| target == :body } + found.nil? ? nil : inputs[found.fetch(0)] + end + + # SEAM-26's construction-time checks, kept apart from the descriptor for the same reason the + # composition is: each half is reviewable on its own, and the descriptor's own body reads as + # the contract rather than the checking. A private_constant. + module Validation + extend self + + # The projections table, validated pair by pair and frozen. + def projections(projections) + unless projections.is_a?(::Hash) + raise Dexpace::InvalidArgumentError, + "projections must be a Hash of input key => [target, wire name], " \ + "got #{projections.class}" + end + + table = projections.to_h { |key, projection| [key, projection(key, projection)] } + if table.count { |_, (target, _)| target == :body } > 1 + raise Dexpace::InvalidArgumentError, "an operation carries at most one body projection" + end + + table + end + + # One [target, wire name] pair, validated and frozen. + def projection(key, projection) + unless projection.is_a?(::Array) && projection.length == 2 + raise Dexpace::InvalidArgumentError, + "projection #{key.inspect} must be a [target, wire name] pair, got " \ + "#{projection.inspect}" + end + + target = target(key, projection.fetch(0)) + name = wire_name(key, projection.fetch(1)) + pair = [target, name] #: [Symbol, String] + pair.freeze + end + + # The target, one of the four parts a projection can name. + def target(key, target) + return target if target.is_a?(::Symbol) && TARGETS.include?(target) + + raise Dexpace::InvalidArgumentError, + "projection #{key.inspect} targets #{target.inspect}; expected one of " \ + "#{TARGETS.map(&:inspect).join(", ")}" + end + + # The wire name, a non-empty String of the model's own. + def wire_name(key, name) + text = name.to_s + if text.empty? + raise Dexpace::InvalidArgumentError, "projection #{key.inspect} has no wire name" + end + + text.dup.freeze + end + + # Two halves of SEAM-27's "every placeholder MUST have a supplied value", checked at + # construction: a placeholder with no projection can never be filled, and a :path projection + # naming no placeholder can never be used. Both are caller mistakes catchable before any + # request exists, which is strictly stronger than catching them at assembly time. + # + # An empty template is legal -- SEAM-27's "an empty path leaves the base untouched". + def template!(text, table) + declared = placeholders(text) + projected = table.each_value.select { |(target, _)| target == :path }.map(&:last) + missing = declared - projected + unless missing.empty? + raise Dexpace::InvalidArgumentError, + "template placeholder(s) #{missing.map(&:inspect).join(", ")} have no path " \ + "projection" + end + + extra = projected - declared + return if extra.empty? + + raise Dexpace::InvalidArgumentError, + "path projection(s) #{extra.map(&:inspect).join(", ")} name no template placeholder" + end + + # The placeholder names, once the template is known to be well-formed: every brace paired, + # the literal text a URI path, and no placeholder empty. + def placeholders(text) + literal!(text) + declared = Operation.placeholders_in(text) + if declared.any?(&:empty?) + raise Dexpace::InvalidArgumentError, "template has an empty placeholder: #{text}" + end + + declared + end + + # The template with its placeholders removed: every brace must be paired, and what is left + # must be an RFC 3986 path. A placeholder's value is not the literal's concern -- it is + # encoded to pchars at assembly -- but the literal text between placeholders is copied onto + # the wire as it stands, so "/x?y", "/a b", "/pets/ü" and "/100%" are refused here, naming + # the template, rather than surfacing as URI::InvalidComponentError from the first + # #build_request. An already-encoded literal such as "/a%20b" is a path and passes. + def literal!(text) + literal = text.gsub(PLACEHOLDER, "") + if BRACE.match?(literal) + raise Dexpace::InvalidArgumentError, "template has an unbalanced brace: #{text}" + end + return if PATH_LITERAL.match?(literal) + + raise Dexpace::InvalidArgumentError, + "template #{text.inspect} is not a URI path: outside a placeholder every " \ + "character must be a pchar or \"/\", and every \"%\" must open a two-digit escape" + end + end + private_constant :Validation + + # SEAM-27's four composition rules, implemented directly and kept apart from the projections + # so the composition is reviewable on its own (design §3 addendum A1). This is a + # concatenation, not RFC 3986 reference resolution: verified on 3.2.11 and 4.0.6 that + # URI::RFC3986_PARSER.join("https://host/c?sig=1", "/pets") is "https://host/pets", discarding + # both the base path segment and the base query the requirement keeps -- which would silently + # drop a signed-URL base's prefix and its SAS query, the exact case SEAM-27's rationale names. + # + # A private_constant: the rules are Operation's and nothing else composes a base URL. + module Composition + extend self + + # Works on a dup of the frozen base and assigns #path and #query rather than re-parsing a + # re-rendered string, which is what keeps already-encoded octets verbatim (verified: a + # frozen URI::Generic dups to an unfrozen copy whose writers work, and the original still + # raises FrozenError). + # + # Both writers validate, and neither is rescued here: every input is checked to a grammar + # at least as tight as the writer's before this runs. #path= refuses anything outside RFC + # 3986 `path-absolute` -- the base path is the parser's own (a `segment` list, the same + # set), the operation path is PATH_LITERAL literal plus encode_component values, and one + # "/" joins them. #query= refuses a "%\H\H" -- the base query is QUERY_LITERAL, the + # operation query is Query#encode, and the "&" between them can complete no escape. A + # rescue would be a line no test can reach. + def compose(base, path, query) + composed = base.dup + composed.path = compose_path(base.path.to_s, path) + composed.query = compose_query(base.query.to_s, query) + composed.freeze + end + + # A trailing slash normalises to exactly one separator; an empty operation path leaves the + # base untouched. + def compose_path(base_path, operation_path) + return base_path if operation_path.empty? + + "#{base_path.sub(%r{/+\z}, "")}/#{operation_path.sub(%r{\A/+}, "")}" + end + + # An existing base query is preserved with the operation query appended after it, its + # dangling separator dropped; either side empty yields the other. + def compose_query(base_query, operation_query) + base = base_query.sub(/&+\z/, "") + return (operation_query.empty? ? nil : operation_query) if base.empty? + return base if operation_query.empty? + + "#{base}&#{operation_query}" + end + end + private_constant :Composition + end +end diff --git a/gems/dexpace-core/lib/dexpace/registry.rb b/gems/dexpace-core/lib/dexpace/registry.rb new file mode 100644 index 0000000..2c33c14 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/registry.rb @@ -0,0 +1,392 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "error/seam_error" +require_relative "error/invalid_argument_error" +require_relative "closeable" +require_relative "version" + +module Dexpace + # Provider discovery, installation and conflict resolution, implemented once and instantiated + # once per surviving seam. + # + # Ruby has no classpath to scan, so design §3.6 keeps all five of SEAM-5's precedence branches + # and changes only the substrate: an adapter registers itself as a side effect of being + # `require`d, and "discoverable" means "the application has required this adapter". Core ships an + # empty registry and never auto-requires an optional gem, which is what keeps SEAM-1 true, and + # the zero-candidate error names no gem, which is what keeps SEAM-2 true in the error path. + # + # The registry maps a key to a *factory* -- the adapter class, or any #call-shaped builder -- + # because require-time registration happens before the application has configured anything. + # Resolution produces an *instance* and the resolved slot holds exactly one. SEAM-6's and + # SEAM-8's prior-state table therefore compares `equal?` on the object in that slot: never ==, + # and never the factory, because two calls of one factory produce two non-equal? instances the + # requirement means to treat as different providers. + # + # State is one frozen Data snapshot in one instance variable, replaced wholesale under a + # Thread::Mutex. A reader takes a single unsynchronised reference read and sees a consistent, + # fully-constructed picture or the previous one, never a torn mixture -- which is SEAM-9's three + # clauses implemented rather than argued, and IO-39's lock-free-read property surviving the seam + # that used to state it. Concurrent::Map would be the styleguide's answer and is a gem + # (docs/knowledge/notes/concurrency-and-async.md). + # + # One class, over the length cap: the five resolution branches, the install table, the swap + # seam and the version-skew guard all read and write one snapshot, and splitting them for a + # line count would scatter one invariant over several files (design §3.6). + class Registry # rubocop:disable Metrics/ClassLength -- one snapshot, one class; see above + # Design §2.3 mandates the two-segment pessimistic form and nothing else. Anything else is + # refused rather than partially reimplemented. + CORE_REQUIREMENT = Regexp.new("\\A~>\\s*(\\d+)\\.(\\d+)\\z", timeout: 1.0) + private_constant :CORE_REQUIREMENT + + # The snapshot: a private_constant, so it does not include Dexpace::Model and has no .build + # (design P2-9). + State = ::Data.define(:factories, :resolved, :handed_out, :explicit, :resolving) + private_constant :State + + # The resolution claim: the gate every other caller waits on, and the fiber that took it. + # + # The owner is what turns a re-entrant #resolve from a silent registry-wide wedge into a loud + # error at the offending call. It is a Fiber and not a Thread for the same reason + # Thread::Mutex's own ownership is per-fiber: two fibers of one thread can genuinely wait on + # each other, and a factory that spawns a thread and resolves from it is not re-entrant at all. + Claim = ::Data.define(:gate, :owner) + private_constant :Claim + + # The Data instance is frozen by construction; its factories map is frozen here so the very + # first snapshot obeys the same rule every later one does (a merge always yields a new map). + EMPTY = State.new( + factories: {}, resolved: nil, handed_out: false, explicit: false, resolving: nil, + ) + EMPTY.factories.freeze + private_constant :EMPTY + + # Whether `object` can be called with `arity` positional arguments. Both transport seams are + # duck types over #call and this is the runtime half of that contract; the RBS interface in + # sig/ is the static half. + # + # Computed from #parameters rather than from #arity: a non-lambda Proc reports its parameters + # as :opt where a lambda reports :req (verified on 3.2.11, 3.4.10 and 4.0.6), so an #arity + # equality would reject `proc { |a, b, c| }`, which SEAM-11's "a bare send lambda works as a + # transport" is meant to admit. A required keyword refuses: the positional count can be right + # and the call still fail, and a callable admitted here that raises ArgumentError at the first + # send is the failure-at-use this predicate exists to move to registration. An object whose + # #call comes from method_missing has no introspectable parameters, and is admitted rather + # than refused. + def self.callable?(object, arity:) + return false unless object.respond_to?(:call) + + callable = object.is_a?(::Proc) || object.is_a?(::Method) ? object : object.method(:call) + accepts_positionals?(callable.parameters, arity) + rescue ::NameError + true + end + + # Whether a parameter list admits a call with exactly `arity` positional arguments and nothing + # else: no required keyword, and the positional slots fit. + def self.accepts_positionals?(parameters, arity) + kinds = parameters.map(&:first) + return false if kinds.include?(:keyreq) + + required = kinds.count(:req) + optional = kinds.count(:opt) + required <= arity && (kinds.include?(:rest) || required + optional >= arity) + end + private_class_method :accepts_positionals? + + # The seam's name, as it appears in every error message. + attr_reader :seam + + # @param seam [String] the seam's name, as it appears in every error message + # @param installer [String] the fully qualified explicit-install entry point, for the hint + # @param conforms [#call] the seam's own .conforms? predicate + def initialize(seam:, installer:, conforms:) + @seam = seam.to_s.freeze + @installer = installer.to_s.freeze + @conforms = conforms + @write = ::Thread::Mutex.new + @state = EMPTY + end + + # The keys of every registered factory, as a fresh list. + def registered_keys = @state.factories.keys + + # Re-registering the `equal?` factory under one key is a no-op, so a double require is quiet; + # a different factory under an occupied key raises, so two gems claiming one key are not. + # `core:` is design §2.3's version-skew guard and is required, because an optional skew check + # is a skew check nobody passes. + # + # The conflict is raised outside the lock: the message calls #inspect on two user objects, + # and a factory whose #inspect reaches back into this registry would otherwise meet + # `ThreadError: recursive locking` instead of the argument error -- the rule this file states + # for every synchronize block is a snapshot swap and nothing else. + def register(key, factory, core:) + assert_core_version!(key, core) + incumbent = @write.synchronize do + current = @state.factories[key] + if current.equal?(factory) + nil + elsif current + current + else + added = { key => factory } #: Hash[untyped, untyped] + @state = @state.with(factories: @state.factories.merge(added).freeze) + nil + end + end + return self if incumbent.nil? + + raise Dexpace::InvalidArgumentError, + "#{@seam} key #{key.inspect} is already registered to #{incumbent.inspect}; " \ + "#{factory.inspect} was rejected" + end + + # SEAM-5's "an explicitly installed provider always wins", with SEAM-6's conflict rule and + # SEAM-8's warning. Both the warning and the conflict are emitted outside the lock: the + # warning writes to a stream, and the conflict's message calls #inspect on two providers + # (#register's note). + def install(provider) + refuse(provider) unless @conforms.call(provider) + incumbent, replaced_after_handout = swap_in(provider) + conflict!(incumbent, provider) unless incumbent.nil? + warn_replaced(provider) if replaced_after_handout + self + end + + # Single-flight, with **no factory call and no #conforms? call under the lock**. The naive + # shape -- build inside @write.synchronize -- deadlocks with `ThreadError: deadlock; recursive + # locking` the moment a factory resolves anything from the same registry, because Ruby's Mutex + # is non-reentrant, and it violates this file's own rule that a mutex is held across a snapshot + # swap and nothing else. + # + # So the claim is a snapshot swap like any other: the winner puts a Thread::Queue in the + # `resolving` slot and builds outside the lock, and every other caller blocks on that queue + # until the winner closes it. That keeps SEAM-9's "a concurrent first-access cannot run the + # discovery scan twice" while the scan itself runs unlocked, and it is scheduler-transparent + # for the same reason the pivot's wait is: a blocking Thread::Queue pop routes through a + # registered Fiber.scheduler rather than parking the OS thread. A resolution that raises clears + # the slot and every waiter retries, which is SEAM-7's "an UNRESOLVED state MUST remain + # re-evaluable". + # + # **A factory that resolves the registry it is being built by raises**, and the check is the + # owner recorded in the claim. Without it that caller parks on the gate it took itself and + # never returns, and every other caller then parks on the same gate: measured, one + # self-resolving thread plus four unrelated resolvers left five hung, and a two-registry cycle + # hung too. The shape this replaced -- build under @write -- raised `ThreadError: deadlock; + # recursive locking` immediately from the offending call, and this comment already named that + # scenario as the motivation for replacing it, so trading a loud error for a silent wedge would + # be a straight regression. The claim is released by #complete_resolution's ensure on this path + # exactly as on any other, so the registry stays re-evaluable for everyone else. + def resolve + loop do + resolved = @state.resolved + return hand_out(resolved) if resolved + + claim, fresh = take_or_join_claim + return complete_resolution(claim.gate) if fresh && claim + + wait_on(claim) + end + end + + # SEAM-6's "separate unchecked/internal swap seam ... for test-scoped overrides", taken + # explicitly and in the safest shape Ruby offers: block-scoped, restoring the prior snapshot in + # an ensure, performing no conflict check. It records the override as auto-resolved rather than + # explicitly installed, because a swap is not an install. + def swap(provider) + raise Dexpace::InvalidArgumentError, "swap requires a block" unless block_given? + + previous = @write.synchronize do + was = @state + @state = was.with(resolved: provider, handed_out: false, explicit: false) + was + end + begin + yield provider + ensure + # Two members are spliced from the LIVE state rather than restored from the captured + # snapshot, and both for the same reason: the live value is the only correct one. + # + # `resolving`, because a resolution can be in flight when the swap begins and can complete + # inside the block, and putting that snapshot's now-closed gate back would leave every + # later #resolve popping a closed queue forever. + # + # `factories`, because a registration is a monotonic require-time fact and Ruby will not + # re-run a `require`. An adapter registers itself as a side effect of being required + # (design §3.6), so `require "some-adapter"` inside a swap block is a registration nothing + # can redo: reverting it loses that adapter for the rest of the process, silently. + # Verified: `swap(:fake) { register(:key, ...) }` left #registered_keys empty. + # + # `resolved`, `explicit` and `handed_out` ARE restored, because scoping an override to a + # block is what #swap is for, and an #install inside the block is part of that override. + @write.synchronize do + live = @state + @state = previous.with(resolving: live.resolving, factories: live.factories) + end + end + end + + private + + # The install swap, and nothing else, under the lock: the conflicting incumbent when there + # is one (and then no swap), and whether the provider replaced one that had already been + # handed out. Both are reported out so the conflict and the warning happen after the block. + def swap_in(provider) + @write.synchronize do + state = @state + next [nil, false] if state.resolved.equal?(provider) + next [state.resolved, false] if state.resolved && state.explicit + + @state = state.with(resolved: provider, handed_out: false, explicit: true) + [nil, !state.resolved.nil? && state.handed_out] + end + end + + # The claim swap: a fresh claim for this fiber when the slot is free, the in-flight claim to + # wait on when it is not, and nothing at all once a provider is resolved. The second element + # says which of the first two happened, because a fresh claim and a re-entrant one both name + # the calling fiber as owner. + def take_or_join_claim + @write.synchronize do + state = @state + if state.resolved + [nil, false] + elsif state.resolving + [state.resolving, false] + else + claim = Claim.new(gate: ::Thread::Queue.new, owner: ::Fiber.current) + @state = state.with(resolving: claim) + [claim, true] + end + end + end + + # Parks on an in-flight claim until its owner closes the gate -- unless the owner is this very + # fiber, which is the re-entrant call and is refused rather than parked. A nil claim means + # the slot was resolved between two reads, and the loop's next read hands it out. + def wait_on(claim) + return if claim.nil? + + raise_reentrant! if claim.owner.equal?(::Fiber.current) + claim.gate.pop unless @state.resolved + end + + def conflict!(incumbent, provider) + raise Dexpace::InvalidArgumentError, + "a #{@seam} provider is already installed (#{incumbent.inspect}); " \ + "#{provider.inspect} was rejected" + end + + def raise_reentrant! + raise Dexpace::SeamError, + "re-entrant #{@seam} resolution: a #{@seam} factory called #resolve on the registry " \ + "that is building it, directly or through a cycle of registries. The provider it " \ + "would return does not exist yet." + end + + # Runs outside @write. + # + # The claim is released in `ensure` and not in a `rescue StandardError`. A factory can raise + # something that is not a StandardError -- a LoadError from an adapter factory that requires + # its own dependency lazily is the realistic case, and NotImplementedError, Interrupt and + # NoMemoryError are the rest -- and a claim released only on the StandardError path leaves a + # closed gate latched in the slot, after which every later #resolve pops a closed queue, + # finds nothing resolved and loops: a silent spin at 100% CPU with no exception, which is + # strictly worse than the deadlock this shape replaced. It also breaks SEAM-7's "an UNRESOLVED + # state MUST remain re-evaluable", which is the contract this method exists to keep. + # + # An explicit #install that won the race while this was building keeps its provider and the + # freshly built one is discarded -- and is closed, because a transport provider is the + # archetypal owner of a pool (SEAM-14) and this phase already closes exactly this shape in + # Completer#fulfil (SEAM-30). close_quietly is a no-op on a provider with no #close. + def complete_resolution(gate) + provider = build_sole_provider + settled = @write.synchronize do + @state = @state.with(resolved: provider, explicit: false) unless @state.resolved + @state.resolved + end + Dexpace.close_quietly(provider) unless settled.equal?(provider) + hand_out(settled) + ensure + @write.synchronize { @state = @state.with(resolving: nil) } + gate.close + end + + def hand_out(provider) + @write.synchronize { @state = @state.with(handed_out: true) } unless @state.handed_out + provider + end + + def build_sole_provider + key = sole_key + factory = @state.factories.fetch(key) + provider = factory.respond_to?(:call) ? factory.call : factory.new + unless @conforms.call(provider) + raise Dexpace::SeamError, + "the #{@seam} factory registered under #{key.inspect} produced a " \ + "#{provider.class}, which does not implement the seam" + end + + provider + end + + # SEAM-5's two loud branches: zero candidates, naming the action and no gem (SEAM-2), and + # more than one, listing every key. + def sole_key + keys = @state.factories.keys + if keys.empty? + raise Dexpace::SeamError, + "no #{@seam} provider is registered. Require an adapter gem that registers one, " \ + "or install one explicitly with #{@installer}." + end + if keys.size > 1 + raise Dexpace::SeamError, + "more than one #{@seam} provider is registered " \ + "(#{keys.map(&:inspect).join(", ")}). Install the one you want explicitly with " \ + "#{@installer}." + end + + keys.fetch(0) + end + + def refuse(provider) + raise Dexpace::InvalidArgumentError, + "a #{@seam} provider must implement the seam; #{provider.class} does not" + end + + # SEAM-8 is a SHOULD asking for a warning rather than a failure. Kernel#warn routes through + # Warning.warn, so a host can intercept, redirect or silence it; verified suppressed when + # $VERBOSE is nil, which is the right property for an advisory -- and it means a consumer + # running -W0 will not see it, which is stated here rather than discovered (design P2-6). + def warn_replaced(provider) + warn("dexpace: a #{@seam} provider that had already been handed out was replaced by " \ + "#{provider.inspect}; objects built against the previous one may still be in use") + end + + # Postponed by phase 0 and built here: the runtime half of design §2.3's version-skew guard. + # + # Hand-rolled rather than built on Gem::Requirement, because `Gem` is undefined under + # `ruby --disable-gems` (verified on 3.2.11 and 4.0.6) and a library may not assume RubyGems is + # loaded; `rubygems` is also not on phase 0's require allowlist. Only the two-segment `~> M.N` + # form design §2.3 mandates is accepted -- anything else is refused rather than partially + # reinterpreted -- and Task 8's test cross-checks this comparison against + # Gem::Requirement#satisfied_by? over a grid, where RubyGems is present. + def assert_core_version!(key, requirement) + match = requirement.is_a?(::String) ? CORE_REQUIREMENT.match(requirement) : nil + unless match + raise Dexpace::InvalidArgumentError, + "core: must be a two-segment pessimistic requirement such as \"~> 1.2\", " \ + "got #{requirement.inspect}" + end + + wanted_major = match[1].to_i + wanted_minor = match[2].to_i + running = Dexpace::VERSION.split(".", 3) + return if running.fetch(0).to_i == wanted_major && running.fetch(1).to_i >= wanted_minor + + raise Dexpace::SeamError, + "#{@seam} adapter #{key.inspect} was built against dexpace-core #{requirement}, " \ + "but dexpace-core #{Dexpace::VERSION} is loaded" + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/serde.rb b/gems/dexpace-core/lib/dexpace/serde.rb new file mode 100644 index 0000000..f2043d7 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/serde.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "registry" +require_relative "serde/error" +require_relative "serde/serialization_error" +require_relative "serde/deserialization_error" + +module Dexpace + # The wire-codec seam: a duck type of six methods, a .conforms? predicate and an RBS interface. + # + # #media_type the media type this serializer produces (SEAM-19) + # #dump_string(value) a fresh String (SEAM-20) + # #dump_bytes(value) a fresh Encoding::BINARY String (SEAM-20) + # #dump_to(value, sink) writes into a caller-owned #write sink; never closes it + # #dump_into(value, buffer, offset:) writes at an offset; IndexError on overflow + # #load(source, witness) reads to EOF; never closes the source (SEAM-21) + # + # All four of SEAM-20's allocation profiles ship and two of them are one Ruby type: a String + # tagged Encoding::BINARY *is* Ruby's byte array, so #dump_bytes differs from #dump_string only + # in the encoding tag -- which is the whole of the distinction the requirement draws (§10.13). + # Both ship because the tag is load-bearing at §3.1's encoding boundary. + # + # #dump(value, sink) is design §3.4's shorthand for #dump_to and an adapter may define it, but it + # is deliberately **not** in CONTRACT: a codec implementing the four named profiles conforms + # without also defining an alias, and requiring the alias would make the shorthand mandatory, + # which is the opposite of what a shorthand is. + # + # SEAM-19's undefaulted media type is enforced here rather than at the codec: .conforms? requires + # #media_type and this module supplies no default and has no fallback constant, so a codec that + # forgets it fails registration instead of silently stamping the wrong Content-Type. That the + # value is *correct* is phase 7's. + # + # SEAM-22's reflective generic type capture is replaced by the witness protocol (§10.14, §7.3, + # phase 7). The clause that survives the substitution is fixed here: #load takes an explicit + # witness and there is no witness-less overload to fall into. + # + # Inside this namespace a bare `JSON` is Dexpace::Serde::JSON once dexpace-serde-json is loaded, + # the same hazard Dexpace::Async carries for `Thread`; Dexpace/QualifiedCoreConstant enforces the + # ::-qualified spelling on every file under lib/dexpace/serde/. + module Serde + CONTRACT = %i[media_type dump_string dump_bytes dump_to dump_into load].freeze + private_constant :CONTRACT + + REGISTRY = Registry.new( + seam: "codec", + installer: "Dexpace::Serde.install", + conforms: ->(object) { Dexpace::Serde.conforms?(object) }, + ) + private_constant :REGISTRY + + class << self + # The runtime half of the seam: every one of the six methods answered. + def conforms?(object) = CONTRACT.all? { |name| object.respond_to?(name) } + + # The seam methods `object` does not answer, so a registration failure can name WHICH of + # the six a codec forgot -- .conforms? returns a Boolean, and "does not implement the seam" + # is the one message SEAM-19 most needs to be specific. + # + # @return [Array] + def missing_methods(object) = CONTRACT.reject { |name| object.respond_to?(name) } + + # Require-time self-registration (design §3.6), with the version-skew guard on `core:`. + def register(key, factory, core:) + REGISTRY.register(key, factory, core: core) + self + end + + # SEAM-5's explicit install, which always wins over a registered factory. + def install(codec) + REGISTRY.install(codec) + self + end + + # The resolved codec (SEAM-5, SEAM-7). + def resolve = REGISTRY.resolve + + # The keys every required codec gem registered under. + def registered_keys = REGISTRY.registered_keys + + # SEAM-6's test-scoped override: block-scoped, unchecked, restored afterwards. + def swap(codec, &) = REGISTRY.swap(codec, &) + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/serde/deserialization_error.rb b/gems/dexpace-core/lib/dexpace/serde/deserialization_error.rb new file mode 100644 index 0000000..0c7cb9e --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/serde/deserialization_error.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "error" + +module Dexpace + module Serde + # The decode half of SEAM-21's failure contract: malformed or type-mismatched input raises + # this, from inside the rescue of the backing library's own failure so #cause is chained; a + # genuine stream I/O error is NOT reclassified and propagates unwrapped (SEAM-21). + class DeserializationError < Error + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/serde/error.rb b/gems/dexpace-core/lib/dexpace/serde/error.rb new file mode 100644 index 0000000..239ed1f --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/serde/error.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../error" + +module Dexpace + module Serde + # The root of the seam's SDK-owned failure hierarchy (SEAM-23). + # + # A class, where phase 1 made the SDK-wide Dexpace::Error a module. The inversion is deliberate + # and the rule for every later phase is: the SDK root is a module, a seam-local root with no + # competing Ruby family is a class. Phase 1's root is a module because XCUT-4 requires transport + # errors to belong to Ruby's IOError family and single inheritance makes a class root and that + # requirement mutually exclusive. Nothing competes here: SEAM-20 and SEAM-21 both say a genuine + # stream I/O error propagates unwrapped rather than being reclassified as a serde failure, so a + # serde error is never also an IOError. SEAM-23 asks in so many words for "a stable, SDK-owned + # hierarchy (a base serde failure with encode/decode subtypes)" that is "open for + # codegen/adapters to add more specific subtypes", and a class root delivers that literally. + # + # Including Dexpace::Error keeps `rescue Dexpace::Error` catching it. Inside this namespace a + # bare `Error` is this class, so core writes the SDK root as Dexpace::Error everywhere. + class Error < ::StandardError + include Dexpace::Error + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/serde/serialization_error.rb b/gems/dexpace-core/lib/dexpace/serde/serialization_error.rb new file mode 100644 index 0000000..85c46e3 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/serde/serialization_error.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "error" + +module Dexpace + module Serde + # The encode half of SEAM-20's failure contract: a codec that cannot serialise a value raises + # this, from inside the rescue of whatever its backing library raised, so Ruby chains the + # original as #cause rather than leaking the library's exception type (serde/5821286d). + class SerializationError < Error + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/transport.rb b/gems/dexpace-core/lib/dexpace/transport.rb new file mode 100644 index 0000000..b3f0066 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/transport.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "registry" +require_relative "closeable" +require_relative "bridge/async_over" +require_relative "error/invalid_argument_error" + +module Dexpace + # The synchronous transport seam: a duck type, a .conforms? predicate, and an RBS interface in + # sig/. There is no module to include and no class to inherit. + # + # A transport is any object responding to #call(request, options, cancellation) and returning a + # Dexpace::Response. #call is the convergence point of Ruby's own middleware ecosystems (Rack, + # Faraday adapters), so a bare lambda is a valid transport, phase 4's Dexpace::Pipeline can stand + # in wherever a transport is expected (PIPE-26), and no adapter has to declare conformance it + # structurally already has. + # + # SEAM-11's three clauses. *Single operation*: one #call, one response; there is no batch entry + # point to add later. *No pre-buffering*: the returned response's body is a lazily-read stream the + # caller owns and closes -- stated at the seam here, proved over a real socket in phase 8 and + # asserted per adapter by dexpace-conformance (phase 8a's TransportSuite). *Options may be + # ignored*: options are always passed and are always an immutable Dexpace::RequestOptions, so + # "behaves identically" is structural rather than a discipline. + # + # SEAM-13's cancellation reaches a transport as the third argument -- an ordinary value, never an + # ambient interrupt -- and a transport honours it by re-checking #cancelled? at every point it + # resumes from a wait. + # + # SEAM-15 is a MAY -- "a port MAY choose a [post-close failure] mode but SHOULD document it" -- + # and the documented mode is narrower than "a send after close raises": **a transport that owns + # the resource it closed raises Dexpace::ClosedError from a later send.** A wrapper that only + # borrows closes nothing and stays usable, which is why both SEAM-18 bridges answer #close, + # release nothing and keep working; raising there would break XCUT-22's "the caller owns its + # lifecycle and may keep using it after the SDK component is closed". Phase 2 ships no owning + # transport, so it ships the error class and the rule and no raise site -- phase 8's adapters are + # the first owners, and dexpace-conformance is where the raise is asserted. + # + # One gap, admitted rather than papered over: the synchronous and asynchronous seams have the + # same structural shape and differ only in return type, so .conforms? cannot tell an async + # transport registered here from a sync one. They are two registries and an adapter names which + # it registers into; dexpace-conformance asserts the return type. A predicate claiming to + # distinguish them would be a false proof, which is the position design §10.10 takes on HTTP-2. + module Transport + REGISTRY = Registry.new( + seam: "transport", + installer: "Dexpace::Transport.install", + conforms: ->(object) { Dexpace::Transport.conforms?(object) }, + ) + private_constant :REGISTRY + + class << self + # The runtime half of the seam: any object callable with the three positional arguments. + def conforms?(object) = Dexpace::Registry.callable?(object, arity: 3) + + # Require-time self-registration (design §3.6): `core:` is the adapter's `~> MAJOR.MINOR` + # requirement on dexpace-core, checked here against Dexpace::VERSION. + def register(key, factory, core:) + REGISTRY.register(key, factory, core: core) + self + end + + # SEAM-5's explicit install, which always wins over a registered factory. + def install(transport) + REGISTRY.install(transport) + self + end + + # The resolved transport, built once from the sole registered factory when nothing was + # installed (SEAM-5, SEAM-7). + def resolve = REGISTRY.resolve + + # The keys every required adapter registered under. + def registered_keys = REGISTRY.registered_keys + + # SEAM-6's test-scoped override: block-scoped, unchecked, restored afterwards. + def swap(transport, &) = REGISTRY.swap(transport, &) + + # SEAM-18's sync-to-async bridge. The executor is required and has no default, because a + # shared global pool would be starved by blocking work -- the requirement says so in as many + # words. It is a duck type exposing #post { ... }; core ships no implementation, which is + # SEAM-1 again, and phase 8's dexpace-async-thread supplies the first one. + def async_over(transport, executor:) + unless conforms?(transport) + raise Dexpace::InvalidArgumentError, + "a transport responds to #call(request, options, cancellation); " \ + "#{transport.class} does not" + end + unless executor.respond_to?(:post) + raise Dexpace::InvalidArgumentError, + "async_over requires an executor responding to #post; there is intentionally " \ + "no default, because a shared global pool would be starved by blocking work" + end + + Dexpace::Bridge::AsyncOver.new(transport, executor) + end + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/async/completer.rbs b/gems/dexpace-core/sig/dexpace/async/completer.rbs new file mode 100644 index 0000000..192a3cf --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/async/completer.rbs @@ -0,0 +1,33 @@ +module Dexpace + module Async + class Completer + # The two callback lists #settle steals under the lock, as one tuple. + type stolen = [Array[^(Dexpace::Async::Settlement) -> void], Array[^(untyped) -> void]] + + @mutex: Thread::Mutex + @gate: Thread::Queue + @outcome: Dexpace::Async::Settlement? + @on_settle: Array[^(Dexpace::Async::Settlement) -> void] + @on_cancel: Array[^(untyped) -> void] + @future: Dexpace::Async::Future? + + attr_reader outcome: Dexpace::Async::Settlement? + + def initialize: () -> void + def future: () -> Dexpace::Async::Future + def settled?: () -> bool + def fulfil: (untyped response) -> bool + def fail: (Exception error) -> bool + def request_cancel: (?untyped reason) -> bool + def on_cancel: () { (untyped) -> void } -> self + def on_settle: () { (Dexpace::Async::Settlement) -> void } -> self + def await: (?Dexpace::Cancellation? cancellation) -> self + + private + + def cancel_reason: (Dexpace::Async::Settlement settlement) -> untyped + def arm: (Dexpace::Cancellation? cancellation) -> Dexpace::Cancellation::Subscription? + def settle: (Dexpace::Async::Settlement settlement) -> bool + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/async/future.rbs b/gems/dexpace-core/sig/dexpace/async/future.rbs new file mode 100644 index 0000000..976de16 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/async/future.rbs @@ -0,0 +1,25 @@ +module Dexpace + module Async + class Future + @completer: Dexpace::Async::Completer + + def initialize: (Dexpace::Async::Completer completer) -> void + def settled?: () -> bool + def cancelled?: () -> bool + def wait: (?cancellation: Dexpace::Cancellation?) -> self + def value: (?cancellation: Dexpace::Cancellation?) -> untyped + def on_settle: () { (Dexpace::Async::Settlement) -> void } -> self + def cancel: (?untyped reason) -> self + def then: () { (untyped) -> untyped } -> Dexpace::Async::Future + + private + + def forward: ( + Dexpace::Async::Settlement settlement, + Dexpace::Async::Completer derived, + ^(untyped) -> untyped block, + ) -> void + def map: (untyped response, Dexpace::Async::Completer derived, ^(untyped) -> untyped block) -> bool + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/async/settlement.rbs b/gems/dexpace-core/sig/dexpace/async/settlement.rbs new file mode 100644 index 0000000..6e42724 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/async/settlement.rbs @@ -0,0 +1,24 @@ +module Dexpace + module Async + class Settlement < Data + include Dexpace::Model + + # `response` is untyped for the reason phase 1's Request#body is: a transport's response is + # what a seam returns, and narrowing it here would fix the async seam's return type ahead of + # phase 8's first adapter. + attr_reader response: untyped + attr_reader error: Exception? + attr_reader cancelled: bool + + private def self.new: (response: untyped, error: Exception?, cancelled: untyped) -> instance + def initialize: (response: untyped, error: Exception?, cancelled: untyped) -> void + + def self.build: (?response: untyped, ?error: Exception?, ?cancelled: untyped) -> Settlement + def self.success: (untyped response) -> Settlement + def self.failure: (Exception error) -> Settlement + def self.cancellation: (Exception error) -> Settlement + + def success?: () -> bool + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/async_transport.rbs b/gems/dexpace-core/sig/dexpace/async_transport.rbs new file mode 100644 index 0000000..0ab8626 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/async_transport.rbs @@ -0,0 +1,24 @@ +module Dexpace + # Differs from _Transport in exactly one position, the return type -- the whole of what + # .conforms? cannot check and the reason both interfaces are declared at all. + interface _AsyncTransport + def call: ( + Dexpace::Request request, + Dexpace::RequestOptions options, + Dexpace::Cancellation? cancellation, + ) -> Dexpace::Async::Future + end + + module AsyncTransport + # A private_constant with no visibility in RBS; declared so Steep can type the delegations. + REGISTRY: Dexpace::Registry + + def self.conforms?: (untyped object) -> bool + def self.register: (untyped key, untyped factory, core: String) -> singleton(Dexpace::AsyncTransport) + def self.install: (untyped transport) -> singleton(Dexpace::AsyncTransport) + def self.resolve: () -> untyped + def self.registered_keys: () -> Array[untyped] + def self.swap: (untyped transport) { (untyped) -> void } -> void + def self.sync_over: (untyped transport) -> Dexpace::Bridge::SyncOver + end +end diff --git a/gems/dexpace-core/sig/dexpace/bridge/async_over.rbs b/gems/dexpace-core/sig/dexpace/bridge/async_over.rbs new file mode 100644 index 0000000..87470d7 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/bridge/async_over.rbs @@ -0,0 +1,20 @@ +module Dexpace + module Bridge + class AsyncOver + include Dexpace::Closeable + + @transport: untyped + @executor: untyped + + def initialize: (untyped transport, untyped executor) -> void + def call: (untyped request, untyped options, Dexpace::Cancellation? cancellation) -> Dexpace::Async::Future + + private + + def deliver: ( + Dexpace::Async::Completer completer, untyped request, untyped options, + Dexpace::Cancellation? cancellation, + ) -> bool + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/bridge/sync_over.rbs b/gems/dexpace-core/sig/dexpace/bridge/sync_over.rbs new file mode 100644 index 0000000..372ccd3 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/bridge/sync_over.rbs @@ -0,0 +1,12 @@ +module Dexpace + module Bridge + class SyncOver + include Dexpace::Closeable + + @transport: untyped + + def initialize: (untyped transport) -> void + def call: (untyped request, untyped options, Dexpace::Cancellation? cancellation) -> untyped + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/cancellation.rbs b/gems/dexpace-core/sig/dexpace/cancellation.rbs new file mode 100644 index 0000000..f34865f --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/cancellation.rbs @@ -0,0 +1,39 @@ +module Dexpace + class Cancellation + # A private_constant with no visibility in RBS; declared so Steep can type .none. + NONE: Dexpace::Cancellation + + private def self.new: (Array[Dexpace::Cancellation::Source] sources) -> instance + def initialize: (Array[Dexpace::Cancellation::Source] sources) -> void + + def self.none: () -> Dexpace::Cancellation + def self.source: () -> Dexpace::Cancellation::Source + def self.over: (*Dexpace::Cancellation::Source) -> Dexpace::Cancellation + def self.any: (*Dexpace::Cancellation) -> Dexpace::Cancellation + + def cancelled?: () -> bool + def reason: () -> untyped + def check!: () -> nil + def on_cancel: () { (untyped) -> void } -> Dexpace::Cancellation::Subscription + def merged_with: (*Dexpace::Cancellation) -> Dexpace::Cancellation + + # Protected in Ruby: only another token composes on it, through #merged_with. + attr_reader sources: Array[Dexpace::Cancellation::Source] + + private + + def once_only: () { () -> void } -> ^(untyped) -> void + def winner: () -> Dexpace::Cancellation::Source? + + class Subscription + @sources: Array[Dexpace::Cancellation::Source] + @hook: (^(untyped) -> void)? + + def initialize: ( + Array[Dexpace::Cancellation::Source] sources, + (^(untyped) -> void)? hook, + ) -> void + def detach: () -> nil + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/cancellation/source.rbs b/gems/dexpace-core/sig/dexpace/cancellation/source.rbs new file mode 100644 index 0000000..4f20a9b --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/cancellation/source.rbs @@ -0,0 +1,28 @@ +module Dexpace + class Cancellation + class Source + # A private_constant snapshot; declared so Steep can type the swap. + class State < Data + attr_reader cancelled: bool + attr_reader reason: untyped + attr_reader cancelled_at: Integer? + + def self.new: (cancelled: bool, reason: untyped, cancelled_at: Integer?) -> instance + end + + @mutex: Thread::Mutex + @state: State + @hooks: Array[^(untyped) -> void] + + attr_reader token: Dexpace::Cancellation + + def initialize: () -> void + def cancelled?: () -> bool + def reason: () -> untyped + def cancelled_at: () -> Integer? + def cancel: (?untyped reason) -> bool + def on_cancel: () { (untyped) -> void } -> self + def off_cancel: (^(untyped) -> void hook) -> self + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/closeable.rbs b/gems/dexpace-core/sig/dexpace/closeable.rbs new file mode 100644 index 0000000..ad2cea1 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/closeable.rbs @@ -0,0 +1,20 @@ +module Dexpace + module Closeable + def initialize_closeable: (?owned: bool) -> nil + def owned?: () -> bool + def closed?: () -> bool + def close: () -> nil + + private + + # Prefixed so an including class's own state cannot collide with the latch (design §3.7). + @dexpace_owned: bool + @dexpace_closed: bool + @dexpace_close_mutex: Thread::Mutex + + def release: () -> void + def ensure_closeable_initialized: () -> void + end + + def self.close_quietly: (untyped resource) -> nil +end diff --git a/gems/dexpace-core/sig/dexpace/error/cancelled_error.rbs b/gems/dexpace-core/sig/dexpace/error/cancelled_error.rbs new file mode 100644 index 0000000..7fd1484 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/error/cancelled_error.rbs @@ -0,0 +1,9 @@ +module Dexpace + class CancelledError < ::StandardError + include Dexpace::Error + + attr_reader reason: untyped + + def initialize: (?untyped reason) -> void + end +end diff --git a/gems/dexpace-core/sig/dexpace/error/closed_error.rbs b/gems/dexpace-core/sig/dexpace/error/closed_error.rbs new file mode 100644 index 0000000..817502d --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/error/closed_error.rbs @@ -0,0 +1,5 @@ +module Dexpace + class ClosedError < ::StandardError + include Dexpace::Error + end +end diff --git a/gems/dexpace-core/sig/dexpace/error/seam_error.rbs b/gems/dexpace-core/sig/dexpace/error/seam_error.rbs new file mode 100644 index 0000000..fe6c0ab --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/error/seam_error.rbs @@ -0,0 +1,5 @@ +module Dexpace + class SeamError < ::StandardError + include Dexpace::Error + end +end diff --git a/gems/dexpace-core/sig/dexpace/hooks.rbs b/gems/dexpace-core/sig/dexpace/hooks.rbs new file mode 100644 index 0000000..8cf9d42 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/hooks.rbs @@ -0,0 +1,9 @@ +# Dexpace::Hooks is a private_constant and not public API (design P2-15): this declaration exists +# because the strict `core` Steep target checks every file under lib/ and needs the module and +# its one method declared to type the three call sites. RBS has no visibility for a constant, so +# the privacy lives in lib/dexpace/hooks.rb alone. +module Dexpace + module Hooks + def self.notify: (Array[^(untyped) -> void] hooks, untyped argument) -> nil + end +end diff --git a/gems/dexpace-core/sig/dexpace/operation.rbs b/gems/dexpace-core/sig/dexpace/operation.rbs new file mode 100644 index 0000000..2b73c84 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/operation.rbs @@ -0,0 +1,64 @@ +module Dexpace + class Operation < Data + include Dexpace::Model + + # Five private_constants with no visibility in RBS; declared so Steep can type the scans. + TARGETS: Array[Symbol] + PLACEHOLDER: Regexp + BRACE: Regexp + PATH_LITERAL: Regexp + QUERY_LITERAL: Regexp + + attr_reader method: Dexpace::Method + attr_reader template: String + attr_reader projections: Hash[untyped, [Symbol, String]] + + private def self.new: ( + method: Dexpace::Method | String | Symbol, template: String, + projections: Hash[untyped, untyped], + ) -> instance + def initialize: ( + method: Dexpace::Method | String | Symbol, template: String, + projections: Hash[untyped, untyped], + ) -> void + + def self.build: ( + method: Dexpace::Method | String | Symbol, + template: String, + ?projections: Hash[untyped, untyped], + ) -> Dexpace::Operation + def self.placeholders_in: (untyped text) -> Array[String] + + def placeholders: () -> Array[String] + def build_request: ( + base_url: String | URI::Generic, + ?inputs: Hash[untyped, untyped], + ) -> Dexpace::Request + + private + + def render_path: (Hash[untyped, untyped] inputs) -> String + def render_query: (Hash[untyped, untyped] inputs) -> String + def render_headers: (Hash[untyped, untyped] inputs) -> Dexpace::Headers + def validated_base: (untyped base_url) -> URI::Generic + def body_input: (Hash[untyped, untyped] inputs) -> untyped + + # Two private_constants with no visibility in RBS; declared so Steep can type the checks + # and the composition. + module Validation + def self?.projections: (untyped projections) -> Hash[untyped, [Symbol, String]] + def self?.projection: (untyped key, untyped projection) -> [Symbol, String] + def self?.target: (untyped key, untyped target) -> Symbol + def self?.wire_name: (untyped key, untyped name) -> String + def self?.template!: (String text, Hash[untyped, [Symbol, String]] table) -> void + def self?.placeholders: (String text) -> Array[String] + def self?.literal!: (String text) -> void + end + + module Composition + def self?.compose: (URI::Generic base, String path, String query) -> URI::Generic + def self?.compose_path: (String base_path, String operation_path) -> String + def self?.compose_query: (String base_query, String operation_query) -> String? + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/registry.rbs b/gems/dexpace-core/sig/dexpace/registry.rbs new file mode 100644 index 0000000..a57cafe --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/registry.rbs @@ -0,0 +1,68 @@ +module Dexpace + class Registry + # Two private_constants with no visibility in RBS, declared so Steep can type the snapshot + # swap. `untyped` on every provider-shaped member is deliberate and is what NFR-11 wants: a + # seam's provider is whatever an adapter supplies, and naming a type here would either be + # `Object` or a constant outside Dexpace::. + CORE_REQUIREMENT: Regexp + EMPTY: State + + class State < Data + attr_reader factories: Hash[untyped, untyped] + attr_reader resolved: untyped + attr_reader handed_out: bool + attr_reader explicit: bool + attr_reader resolving: Claim? + + def self.new: ( + factories: Hash[untyped, untyped], resolved: untyped, handed_out: bool, explicit: bool, + resolving: Claim?, + ) -> instance + def with: ( + ?factories: Hash[untyped, untyped], ?resolved: untyped, ?handed_out: bool, + ?explicit: bool, ?resolving: Claim?, + ) -> State + end + + # The owner is a Fiber, typed untyped so no constant outside Dexpace:: and the stdlib + # allowlist enters a signature (NFR-11); nothing outside the claim swap reads it. + class Claim < Data + attr_reader gate: Thread::Queue + attr_reader owner: untyped + + def self.new: (gate: Thread::Queue, owner: untyped) -> instance + end + + @installer: String + @conforms: ^(untyped) -> bool + @write: Thread::Mutex + @state: State + + attr_reader seam: String + + def self.callable?: (untyped object, arity: Integer) -> bool + private def self.accepts_positionals?: (::Method::param_types parameters, Integer arity) -> bool + + def initialize: (seam: String, installer: String, conforms: ^(untyped) -> bool) -> void + def registered_keys: () -> Array[untyped] + def register: (untyped key, untyped factory, core: String) -> self + def install: (untyped provider) -> self + def resolve: () -> untyped + def swap: (untyped provider) { (untyped) -> void } -> void + + private + + def swap_in: (untyped provider) -> [untyped, bool] + def take_or_join_claim: () -> [Claim?, bool] + def wait_on: (Claim? claim) -> void + def conflict!: (untyped incumbent, untyped provider) -> bot + def raise_reentrant!: () -> bot + def complete_resolution: (Thread::Queue gate) -> untyped + def hand_out: (untyped provider) -> untyped + def build_sole_provider: () -> untyped + def sole_key: () -> untyped + def refuse: (untyped provider) -> bot + def warn_replaced: (untyped provider) -> void + def assert_core_version!: (untyped key, untyped requirement) -> void + end +end diff --git a/gems/dexpace-core/sig/dexpace/serde.rbs b/gems/dexpace-core/sig/dexpace/serde.rbs new file mode 100644 index 0000000..4fbc3a3 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/serde.rbs @@ -0,0 +1,26 @@ +module Dexpace + # The static half of the codec seam: the six methods, typed. `value` and the witness are + # untyped because the witness protocol is phase 7's (§7.3) and a codec's value space is its own. + interface _Codec + def media_type: () -> String + def dump_string: (untyped value) -> String + def dump_bytes: (untyped value) -> String + def dump_to: (untyped value, untyped sink) -> untyped + def dump_into: (untyped value, String buffer, offset: Integer) -> Integer + def load: (untyped source, untyped witness) -> untyped + end + + module Serde + # Two private_constants with no visibility in RBS; declared so Steep can type the module. + CONTRACT: Array[Symbol] + REGISTRY: Dexpace::Registry + + def self.conforms?: (untyped object) -> bool + def self.missing_methods: (untyped object) -> Array[Symbol] + def self.register: (untyped key, untyped factory, core: String) -> singleton(Dexpace::Serde) + def self.install: (untyped codec) -> singleton(Dexpace::Serde) + def self.resolve: () -> untyped + def self.registered_keys: () -> Array[untyped] + def self.swap: (untyped codec) { (untyped) -> void } -> void + end +end diff --git a/gems/dexpace-core/sig/dexpace/serde/deserialization_error.rbs b/gems/dexpace-core/sig/dexpace/serde/deserialization_error.rbs new file mode 100644 index 0000000..0060f36 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/serde/deserialization_error.rbs @@ -0,0 +1,6 @@ +module Dexpace + module Serde + class DeserializationError < Dexpace::Serde::Error + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/serde/error.rbs b/gems/dexpace-core/sig/dexpace/serde/error.rbs new file mode 100644 index 0000000..c6e81f4 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/serde/error.rbs @@ -0,0 +1,7 @@ +module Dexpace + module Serde + class Error < ::StandardError + include Dexpace::Error + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/serde/serialization_error.rbs b/gems/dexpace-core/sig/dexpace/serde/serialization_error.rbs new file mode 100644 index 0000000..f6b4fd9 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/serde/serialization_error.rbs @@ -0,0 +1,6 @@ +module Dexpace + module Serde + class SerializationError < Dexpace::Serde::Error + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/transport.rbs b/gems/dexpace-core/sig/dexpace/transport.rbs new file mode 100644 index 0000000..7cb3d02 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/transport.rbs @@ -0,0 +1,26 @@ +module Dexpace + # The static half of the seam -- what a consumer's own `steep check` sees at a parameter -- and + # the artifact that replaces the styleguide's Sorbet abstract module + # (docs/knowledge/notes/data-modeling.md). Typed, not untyped: every name is a Dexpace:: + # constant, so NFR-11 holds, and an untyped interface would carry no information. + interface _Transport + def call: ( + Dexpace::Request request, + Dexpace::RequestOptions options, + Dexpace::Cancellation? cancellation, + ) -> Dexpace::Response + end + + module Transport + # A private_constant with no visibility in RBS; declared so Steep can type the delegations. + REGISTRY: Dexpace::Registry + + def self.conforms?: (untyped object) -> bool + def self.register: (untyped key, untyped factory, core: String) -> singleton(Dexpace::Transport) + def self.install: (untyped transport) -> singleton(Dexpace::Transport) + def self.resolve: () -> untyped + def self.registered_keys: () -> Array[untyped] + def self.swap: (untyped transport) { (untyped) -> void } -> void + def self.async_over: (untyped transport, executor: untyped) -> Dexpace::Bridge::AsyncOver + end +end diff --git a/gems/dexpace-core/test/dexpace/async/completer_test.rb b/gems/dexpace-core/test/dexpace/async/completer_test.rb new file mode 100644 index 0000000..a440b6f --- /dev/null +++ b/gems/dexpace-core/test/dexpace/async/completer_test.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# SEAM-16, SEAM-17, SEAM-30. The state lives on the Completer and the Future is a facade over it, +# because Ruby has no package-private visibility and the alternative is a cross-object `send`. +# Two classes because Metrics/ClassLength caps one at 100 lines: settlement and SEAM-30 here, +# cancellation and the callback lists in Hooks. +class DexpaceAsyncCompleterTest < DexpaceTestCase + # A closeable response that counts its closes, so SEAM-30's orphan close is observable. + class FakeResponse + include Dexpace::Closeable + + attr_reader :closes + + def initialize + @closes = 0 + initialize_closeable(owned: true) + end + + private + + def release + @closes += 1 + end + end + + def completer = Dexpace::Async::Completer.new + + test "a fulfilled future delivers the response and never closes it" do + subject = completer + response = FakeResponse.new + + assert(subject.fulfil(response)) + + assert_same(response, subject.future.value) + assert_equal(0, response.closes, "SEAM-16: a delivered response is the caller's to close") + end + + test "a failed future re-raises the identical exception object" do + subject = completer + boom = ::IOError.new("connection reset") + + assert(subject.fail(boom)) + + caught = assert_raises(::IOError) { subject.future.value } + assert_same(boom, caught, "SEAM-18: there is no wrapper to unwrap") + end + + test "fulfil that loses the race closes the orphan exactly once" do + subject = completer + + assert(subject.fail(::IOError.new("already gone"))) + orphan = FakeResponse.new + + refute(subject.fulfil(orphan)) + + assert_equal(1, orphan.closes, "SEAM-30: the caller-closes rule cannot apply to a value no " \ + "caller receives",) + end + + test "cancelling after settlement does not close the delivered response" do + subject = completer + response = FakeResponse.new + subject.fulfil(response) + + subject.future.cancel(:too_late) + + assert_equal(0, response.closes, "SEAM-16's last clause, and ASYNC-20") + refute_predicate(subject.future, :cancelled?) + assert_same(response, subject.future.value) + end + + test "cancelling before settlement settles as cancelled and raises with the reason" do + subject = completer + + subject.future.cancel(:stop) + + assert_predicate(subject.future, :settled?) + assert_predicate(subject.future, :cancelled?) + error = assert_raises(Dexpace::CancelledError) { subject.future.value } + assert_equal(:stop, error.reason) + end + + test "concurrent settles elect exactly one winner" do + subject = completer + wins = ::Queue.new + + Array.new(16) { |i| ::Thread.new { wins << i if subject.fail(::IOError.new(i.to_s)) } } + .each(&:join) + + assert_equal(1, wins.size) + end + + test "fail refuses anything that is not an exception" do + assert_raises(Dexpace::InvalidArgumentError) { completer.fail(:not_an_exception) } + end + + test "outcome is nil until settled, then the settlement" do + subject = completer + + assert_nil(subject.outcome) + refute_predicate(subject, :settled?) + subject.fulfil(:value) + + assert_equal(Dexpace::Async::Settlement.success(:value), subject.outcome) + end + + test "future is memoised and on_settle and on_cancel require a block" do + subject = completer + + assert_same(subject.future, subject.future) + assert_raises(Dexpace::InvalidArgumentError) { subject.on_settle } + assert_raises(Dexpace::InvalidArgumentError) { subject.on_cancel } + end + + test "request_cancel after settlement is a no-op that reports false" do + subject = completer + subject.fulfil(:value) + + refute(subject.request_cancel(:late)) + end + + # Cancellation, the abort hooks and the settle callbacks. + class Hooks < DexpaceTestCase + def completer = Dexpace::Async::Completer.new + + test "on_cancel gives the producer a hook to abort promptly" do + subject = completer + seen = [] + subject.on_cancel { |reason| seen << reason } + + subject.future.cancel(:abort) + + assert_equal([:abort], seen) + end + + test "on_cancel registered after a cancellation fires at once, and after a settle never" do + cancelled = completer + cancelled.future.cancel(:gone) + seen = [] + + cancelled.on_cancel { |reason| seen << reason } + + assert_equal([:gone], seen) + + settled = completer + settled.fulfil(FakeResponse.new) + + settled.on_cancel { flunk("a settled future has no cancellation to report") } + end + + # The outcome is published BEFORE the cancel hooks run. Notify-then-settle leaves the future + # permanently unsettled when one hook raises, so every #value on it blocks forever -- the same + # failure Bridge::AsyncOver#deliver's rescue closes, reached through the public API alone. The + # join timeout is the assertion: without it the suite hangs instead of failing. + test "a raising cancel hook still leaves the future settled and every waiter unblocked" do + subject = completer + subject.on_cancel { raise ::IOError, "the producer's abort hook blew up" } + + assert_raises(::IOError) { subject.future.cancel(:stop) } + + assert_predicate(subject, :settled?, "cancellation must always publish an outcome") + assert_predicate(subject.future, :cancelled?) + waiter = ::Thread.new do + subject.future.value + rescue Dexpace::CancelledError => error + error + end + + assert(waiter.join(5), "the future never settled and #value would block forever") + assert_equal(:stop, waiter.value.reason) + end + + test "one raising settle callback does not drop the callbacks registered after it" do + subject = completer + seen = [] + subject.on_settle { seen << :first } + subject.on_settle { raise ::IOError, "a settle callback blew up" } + subject.on_settle { seen << :third } + + assert_raises(::IOError) { subject.fulfil(FakeResponse.new) } + + assert_equal(%i[first third], seen) + assert_predicate(subject, :settled?) + end + + test "on_settle runs once whether registered before or after settlement" do + subject = completer + seen = [] + subject.future.on_settle { |settlement| seen << [:before, settlement.success?] } + + subject.fulfil(FakeResponse.new) + subject.future.on_settle { |settlement| seen << [:after, settlement.success?] } + + assert_equal([[:before, true], [:after, true]], seen) + end + end +end diff --git a/gems/dexpace-core/test/dexpace/async/future_scheduler_test.rb b/gems/dexpace-core/test/dexpace/async/future_scheduler_test.rb new file mode 100644 index 0000000..f325cb6 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/async/future_scheduler_test.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../support/probe_scheduler" +require "dexpace" + +# SEAM-17. Design §3.3 claims the pivot is scheduler-transparent -- that a caller inside Async { } +# awaits it without blocking the reactor. That claim rests on one Ruby fact: a blocking +# Thread::Queue pop routes through a registered Fiber.scheduler's block/unblock hooks instead of +# parking the OS thread. This asserts the fact rather than restating the claim, and it runs on +# every row of the CI matrix, which is what makes it a standing check. +class DexpaceAsyncFutureSchedulerTest < DexpaceTestCase + test "value blocks through the scheduler, not the thread" do + scheduler = ProbeScheduler.new + completer = Dexpace::Async::Completer.new + delivered = [] + + Fiber.set_scheduler(scheduler) + begin + Fiber.schedule { delivered << completer.future.value } + Fiber.schedule { completer.fulfil(:response) } + ensure + Fiber.set_scheduler(nil) + end + + assert_equal([:response], delivered) + assert_includes(scheduler.hooks, [:block, "Thread::Queue"]) + assert_includes(scheduler.hooks, [:unblock, "Thread::Queue"]) + end + + # The registry's single-flight gate is the same Thread::Queue wait, so a second resolver inside + # a scheduler parks on the scheduler too rather than on the OS thread. + test "a settled future never touches the scheduler at all" do + scheduler = ProbeScheduler.new + completer = Dexpace::Async::Completer.new + completer.fulfil(:response) + delivered = [] + + Fiber.set_scheduler(scheduler) + begin + Fiber.schedule { delivered << completer.future.value } + ensure + Fiber.set_scheduler(nil) + end + + assert_equal([:response], delivered) + assert_empty(scheduler.hooks) + end +end diff --git a/gems/dexpace-core/test/dexpace/async/future_shadowing_test.rb b/gems/dexpace-core/test/dexpace/async/future_shadowing_test.rb new file mode 100644 index 0000000..8136763 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/async/future_shadowing_test.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# SEAM-16, SEAM-17 -- the pivot's implementation under the adapter namespace; design §9 Addendum +# A1 and verified fact 2. dexpace-async-thread defines Dexpace::Async::Thread, +# after which a bare `Thread` written inside module Dexpace::Async resolves to that module rather +# than to Ruby's class -- and dexpace-core's own suite never requires that gem, so this is the only +# place core can catch it. Task 3's cop is the other half: the cop catches it on a file nobody ran, +# this catches it on a file nobody linted. +# +# This test defines a constant that outlives it. It is in its own file, and it is the last word on +# the subject: nothing else in the suite may assume Dexpace::Async::Thread is undefined. +class DexpaceAsyncFutureShadowingTest < DexpaceTestCase + def stand_in! + return if Dexpace::Async.const_defined?(:Thread, false) + + Dexpace::Async.const_set(:Thread, Module.new) + end + + test "the pivot still works once the adapter gem's namespace exists" do + stand_in! + + completer = Dexpace::Async::Completer.new + response = Object.new + producer = ::Thread.new { completer.fulfil(response) } + + assert_same(response, completer.future.value) + + producer.join + + assert_instance_of(Module, Dexpace::Async::Thread, + "the stand-in is still a bare Module, so the pivot did not reach Ruby's " \ + "Thread by accident",) + end + + # Every ::-qualified constant the pivot reaches, exercised once each with the stand-in present: + # Mutex (the completer's), Queue (the gate) and the cancellation guard's Mutex. + test "cancellation through a composed token still works under the stand-in" do + stand_in! + completer = Dexpace::Async::Completer.new + source = Dexpace::Cancellation.source + token = Dexpace::Cancellation.any(source.token, Dexpace::Cancellation.source.token) + canceller = ::Thread.new do + sleep(0.02) + source.cancel(:stop) + end + + error = assert_raises(Dexpace::CancelledError) { completer.future.value(cancellation: token) } + + assert_equal(:stop, error.reason) + canceller.join + end +end diff --git a/gems/dexpace-core/test/dexpace/async/future_test.rb b/gems/dexpace-core/test/dexpace/async/future_test.rb new file mode 100644 index 0000000..b1ac712 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/async/future_test.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# SEAM-16, SEAM-18's interruption clause. #value blocks on a Thread::Queue pop rather than a spin +# or a Kernel#sleep poll, which is what makes the pivot scheduler-transparent (Task 6 proves it). +class DexpaceAsyncFutureTest < DexpaceTestCase + test "value blocks until another thread settles, rather than spinning" do + completer = Dexpace::Async::Completer.new + response = Object.new + producer = ::Thread.new do + sleep(0.05) + completer.fulfil(response) + end + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + assert_same(response, completer.future.value) + + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started + + assert_operator(elapsed, :>=, 0.04, "the waiter really blocked") + producer.join + end + + test "value honours a cancellation token and settles the future as cancelled" do + completer = Dexpace::Async::Completer.new + source = Dexpace::Cancellation.source + canceller = ::Thread.new do + sleep(0.02) + source.cancel(:caller_gave_up) + end + + error = assert_raises(Dexpace::CancelledError) do + completer.future.value(cancellation: source.token) + end + + assert_equal(:caller_gave_up, error.reason) + assert_predicate(completer.future, :cancelled?) + canceller.join + end + + test "value on an already-cancelled token settles at once without waiting" do + completer = Dexpace::Async::Completer.new + source = Dexpace::Cancellation.source + source.cancel(:already) + + error = assert_raises(Dexpace::CancelledError) do + completer.future.value(cancellation: source.token) + end + + assert_equal(:already, error.reason) + end + + test "wait returns self and never raises the failure" do + completer = Dexpace::Async::Completer.new + completer.fail(::IOError.new("boom")) + + future = completer.future + + assert_same(future, future.wait) + assert_predicate(future, :settled?) + end + + test "the none token is accepted and arms nothing" do + completer = Dexpace::Async::Completer.new + completer.fulfil(:ok) + + assert_equal(:ok, completer.future.value(cancellation: Dexpace::Cancellation.none)) + end + + test "a non-token cancellation is a caller mistake, not a NoMethodError from inside the pivot" do + completer = Dexpace::Async::Completer.new + + error = assert_raises(Dexpace::InvalidArgumentError) do + completer.future.value(cancellation: :not_a_token) + end + + assert_match(/Dexpace::Cancellation/, error.message) + end + + test "a future refuses to be built over anything but a completer" do + error = assert_raises(Dexpace::InvalidArgumentError) { Dexpace::Async::Future.new(Object.new) } + + assert_match(/Completer/, error.message) + end + + test "settled? and cancelled? are public; the completer is not exposed" do + future = Dexpace::Async::Completer.new.future + + assert_respond_to(future, :settled?) + assert_respond_to(future, :cancelled?) + refute_respond_to(future, :completer) + end + + # #then: the one combinator on the read side (design P2-11). + class Then < DexpaceTestCase + # A closeable value, so SEAM-30's orphan close is observable on a mapped value. + class Mapped + include Dexpace::Closeable + + attr_reader :closes + + def initialize + @closes = 0 + initialize_closeable(owned: true) + end + + private + + def release + @closes += 1 + end + end + + test "then derives a future settled with the block's result" do + completer = Dexpace::Async::Completer.new + derived = completer.future.then { |response| "mapped #{response}" } + + completer.fulfil("ok") + + assert_equal("mapped ok", derived.value) + end + + test "then maps a future that had already settled" do + completer = Dexpace::Async::Completer.new + completer.fulfil(2) + + assert_equal(4, completer.future.then { |value| value * 2 }.value) + end + + test "then forwards the failure unchanged, as the same object" do + boom = ::IOError.new("reset") + completer = Dexpace::Async::Completer.new + derived = completer.future.then { |_value| flunk("the block must not run on a failure") } + + completer.fail(boom) + + assert_same(boom, assert_raises(::IOError) { derived.value }) + end + + test "then forwards cancellation as cancellation, not as a plain failure" do + completer = Dexpace::Async::Completer.new + derived = completer.future.then { |_value| flunk("the block must not run on a cancellation") } + + completer.future.cancel(:caller_gave_up) + + error = assert_raises(Dexpace::CancelledError) { derived.value } + assert_equal(:caller_gave_up, error.reason) + assert_predicate(derived, :cancelled?, "a derived future that lost its source is cancelled") + end + + test "cancelling the derived future cancels the one it was derived from" do + completer = Dexpace::Async::Completer.new + derived = completer.future.then { |value| value } + + derived.cancel(:downstream_gave_up) + + assert_predicate(completer.future, :cancelled?, "SEAM-18's bidirectional cancellation") + assert_equal(:downstream_gave_up, completer.outcome.error.reason) + assert_predicate(derived, :cancelled?) + end + + test "a raising block fails the derived future rather than escaping the settling thread" do + completer = Dexpace::Async::Completer.new + derived = completer.future.then { |_value| raise(::ArgumentError, "bad map") } + + completer.fulfil(:source) + + assert_equal("bad map", assert_raises(::ArgumentError) { derived.value }.message) + assert_predicate(completer.future, :settled?, "the source future still settled successfully") + end + + # SEAM-30 reaches a mapped value because #then settles through Completer#fulfil. The race is + # made deterministic by cancelling the derived future from inside the mapping block: the block + # still returns its value, and #fulfil then loses to the cancellation and closes it. + test "a mapped value that loses the completion race is closed, not leaked" do + completer = Dexpace::Async::Completer.new + mapped = Mapped.new + derived = completer.future.then do |_value| + derived.cancel(:changed_my_mind) + mapped + end + + completer.fulfil(:source) + + assert_predicate(derived, :cancelled?) + assert_equal(1, mapped.closes) + end + + test "then requires a block" do + assert_raises(Dexpace::InvalidArgumentError) { Dexpace::Async::Completer.new.future.then } + end + end +end diff --git a/gems/dexpace-core/test/dexpace/async/settlement_test.rb b/gems/dexpace-core/test/dexpace/async/settlement_test.rb new file mode 100644 index 0000000..3ebc4b5 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/async/settlement_test.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# SEAM-16: there is no "settled with nothing" state to reach, because settling means writing +# exactly one of a response or an error. That is enforced in initialize, not in a builder, per +# phase 1's construction rule. +class DexpaceAsyncSettlementTest < DexpaceTestCase + test "exactly one of response or error, and cancelled implies error" do + assert_raises(Dexpace::InvalidArgumentError) { Dexpace::Async::Settlement.build } + assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::Async::Settlement.build(response: :r, error: ::IOError.new) + end + assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::Async::Settlement.build(response: :r, cancelled: true) + end + end + + test "the three factories build the three shapes" do + assert_predicate(Dexpace::Async::Settlement.success(:response), :success?) + refute_predicate(Dexpace::Async::Settlement.failure(::IOError.new), :success?) + cancelled = Dexpace::Async::Settlement.cancellation(Dexpace::CancelledError.new(:why)) + + assert(cancelled.cancelled) + refute_predicate(cancelled, :success?) + assert_equal(:why, cancelled.error.reason) + end + + test "the members are exactly response, error and cancelled, and cancelled is a boolean" do + settled = Dexpace::Async::Settlement.build(error: ::IOError.new("x"), cancelled: nil) + + assert_equal(%i[response error cancelled], settled.to_h.keys) + assert_same(false, settled.cancelled, "coerced to a literal false, never left nil") + assert_nil(settled.response) + end + + # Data#with does not call an initialize override on Ruby 3.2 (verified 3.2.11 / 3.4.10 / 4.0.6), + # so this passes on 3.4 and 4.0 without phase 1's shared #with and fails on the declared floor. + test "with re-validates on every supported Ruby" do + settled = Dexpace::Async::Settlement.success(:response) + + assert_raises(Dexpace::InvalidArgumentError) { settled.with(response: nil) } + end + + test "a settlement is frozen and equal by value" do + settled = Dexpace::Async::Settlement.success(:response) + + assert_predicate(settled, :frozen?) + assert_equal(Dexpace::Async::Settlement.success(:response), settled) + end + + test "new is private" do + refute_respond_to(Dexpace::Async::Settlement, :new) + end +end diff --git a/gems/dexpace-core/test/dexpace/async_transport_test.rb b/gems/dexpace-core/test/dexpace/async_transport_test.rb new file mode 100644 index 0000000..cd5230d --- /dev/null +++ b/gems/dexpace-core/test/dexpace/async_transport_test.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../test_helper" +require_relative "../support/fake_async_transport" +require "dexpace" + +# SEAM-16 and SEAM-2. A separate top-level constant and a separate registry from +# Dexpace::Transport, which the design does not name (deviation P2-1): SEAM-2 enumerates the +# synchronous and asynchronous transports as two distinct seams, so one registry keyed by kind +# would merge two concerns the requirement separates. The name is not Dexpace::Transport::Async, +# because that constant would sit beside the adapter namespaces Dexpace::Transport::NetHTTP and +# ::AsyncHTTP -- a seam beside its own implementations. +class DexpaceAsyncTransportTest < DexpaceTestCase + CORE = "~> 0.0" + + test "conforms? has the same shape as the sync seam's, and that is an admitted gap" do + assert(Dexpace::AsyncTransport.conforms?(FakeAsyncTransport.new)) + assert(Dexpace::AsyncTransport.conforms?(->(_r, _o, _c) {})) + refute(Dexpace::AsyncTransport.conforms?(Object.new)) + refute(Dexpace::AsyncTransport.conforms?(->(_r, _o, _c, must:) {})) + # The two seams differ only in return type, which no predicate can check before the first + # call. They are two registries, and an adapter names which it registers into. + assert(Dexpace::Transport.conforms?(FakeAsyncTransport.new)) + end + + test "the registry starts empty and is not the sync seam's" do + assert_empty(Dexpace::AsyncTransport.registered_keys) + + Dexpace::Transport.swap(->(_r, _o, _c) { :sync }) do + assert_raises(Dexpace::SeamError) { Dexpace::AsyncTransport.resolve } + end + end + + test "the zero-candidate error names this seam and no gem" do + error = assert_raises(Dexpace::SeamError) { Dexpace::AsyncTransport.resolve } + + assert_match(/no async transport provider is registered/, error.message) + assert_match(/Dexpace::AsyncTransport\.install/, error.message) + refute_match(/async_http|net_http/i, error.message, "SEAM-2") + end + + test "install refuses a non-conforming object and swap scopes an override" do + assert_raises(Dexpace::InvalidArgumentError) { Dexpace::AsyncTransport.install(Object.new) } + + transport = FakeAsyncTransport.new + + Dexpace::AsyncTransport.swap(transport) do + assert_same(transport, Dexpace::AsyncTransport.resolve) + end + + assert_raises(Dexpace::SeamError) { Dexpace::AsyncTransport.resolve } + end + + test "install goes through the module, returns it, and is scoped by an enclosing swap" do + transport = FakeAsyncTransport.new + + Dexpace::AsyncTransport.swap(:override) do + assert_same(Dexpace::AsyncTransport, Dexpace::AsyncTransport.install(transport)) + assert_same(transport, Dexpace::AsyncTransport.resolve) + end + + assert_raises(Dexpace::SeamError) { Dexpace::AsyncTransport.resolve } + end + + # The registration is scoped inside a swap and cleaned out of the private registry afterwards, + # for the reason transport_test.rb gives: registrations are process-global and kept by design. + test "register goes through the module and returns the module" do + Dexpace::AsyncTransport.swap(:override) do + registered = Dexpace::AsyncTransport.register(:probe, -> { :p }, core: CORE) + + assert_same(Dexpace::AsyncTransport, registered) + assert_includes(Dexpace::AsyncTransport.registered_keys, :probe) + end + ensure + registry = Dexpace::AsyncTransport.const_get(:REGISTRY, false) + state = registry.instance_variable_get(:@state) + registry.instance_variable_set(:@state, state.with(factories: state.factories.except(:probe))) + end + + test "a future that settles with a response is delivered, and the caller closes it" do + response = Object.new + transport = FakeAsyncTransport.new(response: response) + + future = transport.call(:request, nil, nil) + + assert_instance_of(Dexpace::Async::Future, future) + assert_same(response, future.value, "SEAM-16: never a null success") + end + + test "sync_over refuses a non-conforming transport" do + assert_raises(Dexpace::InvalidArgumentError) { Dexpace::AsyncTransport.sync_over(Object.new) } + end +end diff --git a/gems/dexpace-core/test/dexpace/bridge/async_over_test.rb b/gems/dexpace-core/test/dexpace/bridge/async_over_test.rb new file mode 100644 index 0000000..82ea502 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/bridge/async_over_test.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../support/fake_transport" +require_relative "../../support/inline_executor" +require "dexpace" + +# SEAM-18's sync-to-async half, and the one place in phase 2 where core itself can produce a +# response no caller will take delivery of -- which is where SEAM-30 is exercised rather than +# merely stated. +class DexpaceBridgeAsyncOverTest < DexpaceTestCase + # A closeable response that counts its closes, so SEAM-30's orphan close is observable. + class FakeResponse + include Dexpace::Closeable + + attr_reader :closes + + def initialize + @closes = 0 + initialize_closeable(owned: true) + end + + private + + def release + @closes += 1 + end + end + + test "delivers the response and threads the exact options and token through" do + response = FakeResponse.new + sync = FakeTransport.new(response: response) + async = Dexpace::Transport.async_over(sync, executor: InlineExecutor.new) + options = Dexpace::RequestOptions::EMPTY + token = Dexpace::Cancellation.none + + future = async.call(:request, options, token) + + assert_same(response, future.value) + assert_same(options, sync.calls.first[1], "SEAM-18: per-call options are threaded, not dropped") + assert_same(token, sync.calls.first[2]) + assert_equal(0, response.closes, "a delivered response is the caller's to close") + end + + test "routes a synchronous raise from the wrapped transport to the failure channel" do + boom = ::IOError.new("connection reset") + async = Dexpace::Transport.async_over( + FakeTransport.new(raises: boom), executor: InlineExecutor.new, + ) + + future = async.call(:request, nil, nil) + + caught = assert_raises(::IOError) { future.value } + assert_same(boom, caught, "ASYNC-2 / PIPE-30: normalised to the failure channel, not wrapped") + end + + test "closes the orphaned response when cancellation wins the completion race" do + response = FakeResponse.new + source = Dexpace::Cancellation.source + sync = FakeTransport.new(response: response, before_return: -> { source.cancel(:gave_up) }) + async = Dexpace::Transport.async_over(sync, executor: InlineExecutor.new) + + future = async.call(:request, nil, source.token) + + assert_raises(Dexpace::CancelledError) { future.value } + assert_equal(1, response.closes, + "SEAM-30: check-after-resume closes a response no caller receives",) + end + + # Both transport seams share one .conforms? -- Dexpace::Registry.callable?(object, arity: 3) -- + # and #parameters cannot see a return type, so an async transport is accepted at construction. + # Without #deliver's guard the outer future delivers the INNER future, nothing raises anywhere, + # and the real response is never closed. Phase 4c's Pipeline/AsyncPipeline pair is what makes it + # cheap to hit; the finding is 4c's plan's, routed to this task. + test "an async transport handed to async_over raises at the first send" do + inner = Dexpace::Async::Completer.new + async = Dexpace::Transport.async_over( + ->(_request, _options, _cancellation) { inner.future }, executor: InlineExecutor.new, + ) + + future = async.call(:request, nil, nil) + + error = assert_raises(Dexpace::SeamError) { future.value } + assert_match(/SYNCHRONOUS transport/, error.message) + assert_match(/Dexpace::Async::Future/, error.message, "the message names the class it got") + end + + # Check-before-dispatch. ASYNC-3's third clause holds without it -- nothing here is ever + # interrupted -- and SEAM-30 closes the orphan either way. What it saves is one round trip and, + # on a non-idempotent method, one server-side side effect a caller believed they had cancelled. + # It belongs to core because the pool posts an opaque block and cannot see the token. + test "a token cancelled before the block runs never reaches the transport at all" do + source = Dexpace::Cancellation.source + source.cancel(:gave_up) + sync = FakeTransport.new(response: :ok) + async = Dexpace::Transport.async_over(sync, executor: InlineExecutor.new) + + future = async.call(:request, nil, source.token) + + error = assert_raises(Dexpace::CancelledError) { future.value } + assert_equal(:gave_up, error.reason) + assert_empty(sync.calls, "check-before-dispatch: the send was never made") + end + + test "the bridge's product conforms to the async seam" do + async = Dexpace::Transport.async_over(FakeTransport.new, executor: InlineExecutor.new) + + assert(Dexpace::AsyncTransport.conforms?(async)) + end + + # ASYNC-2 and PIPE-30 want one normalisation: a caller of an async seam never has to rescue + # around #call. A shut-down pool raising from #post is the same class of failure as the wrapped + # transport raising, and goes to the same channel. + test "a raise from the executor itself settles the future rather than escaping" do + exploding = Class.new do + def post = raise(::IOError, "the pool is shut down") + end.new + async = Dexpace::Transport.async_over(FakeTransport.new(response: :ok), executor: exploding) + + future = async.call(:request, nil, nil) + + error = assert_raises(::IOError) { future.value } + assert_equal("the pool is shut down", error.message) + end + + # deliver's delivery branch must sit INSIDE its rescue. Written as a method-level `else` it does + # not, so anything raised between the send returning and the future settling escapes the posted + # block -- and under a real threaded executor that kills the worker and leaves the future + # permanently unsettled. InlineExecutor masks it, because #call's own rescue catches what + # escapes; this test supplies a threaded executor so it cannot. The raise is a real one: a + # transport returning nil trips Settlement's "exactly one of response or error" rule inside + # Completer#fulfil, which is SEAM-16's "MUST NOT complete successfully with a null value" -- + # so a nil response is a failure delivered through the failure channel, never a future that + # nobody can wait on. + test "a raise after the send but before the settle still settles the future" do + threaded = Class.new do + attr_reader :workers + + def initialize = @workers = [] + + def post(&block) = @workers << ::Thread.new(&block) + end.new + async = Dexpace::Transport.async_over(FakeTransport.new(response: nil), executor: threaded) + + future = async.call(:request, nil, nil) + + settled = ::Thread.new { future.wait } + + assert(settled.join(5), "ASYNC-2/PIPE-30: the future never settled and #value would block") + assert_raises(Dexpace::InvalidArgumentError) { future.value } + threaded.workers.each(&:join) + end + + test "the bridge is closeable, owns nothing, and leaves the wrapped transport usable" do + transport = FakeTransport.new(response: :ok) + async = Dexpace::Transport.async_over(transport, executor: InlineExecutor.new) + + refute_predicate(async, :owned?, "SEAM-14 / XCUT-22: transport and executor are the caller's") + assert_nil(async.close) + assert_predicate(async, :closed?) + assert_nil(async.close, "SEAM-14: close is idempotent") + assert_equal(:ok, async.call(:request, nil, nil).value, + "close released nothing, because the bridge created nothing",) + end +end diff --git a/gems/dexpace-core/test/dexpace/bridge/sync_over_test.rb b/gems/dexpace-core/test/dexpace/bridge/sync_over_test.rb new file mode 100644 index 0000000..0b013d5 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/bridge/sync_over_test.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../support/fake_async_transport" +require "dexpace" + +# SEAM-18's async-to-sync half, clause by clause. +class DexpaceBridgeSyncOverTest < DexpaceTestCase + test "returns the delivered response" do + response = Object.new + sync = Dexpace::AsyncTransport.sync_over(FakeAsyncTransport.new(response: response)) + + assert_same(response, sync.call(:request, nil, nil)) + end + + test "surfaces the original failure, not a wrapper" do + boom = ::IOError.new("reset") + sync = Dexpace::AsyncTransport.sync_over(FakeAsyncTransport.new(raises: boom)) + + caught = assert_raises(::IOError) { sync.call(:request, nil, nil) } + + assert_same(boom, caught, "SEAM-18: the pivot never wraps, so there is nothing to unwrap") + end + + test "honours cancellation by cancelling the in-flight future and raising" do + transport = FakeAsyncTransport.new(response: :never, settle_later: true) + sync = Dexpace::AsyncTransport.sync_over(transport) + source = Dexpace::Cancellation.source + canceller = ::Thread.new do + sleep(0.02) + source.cancel(:interrupted) + end + + error = assert_raises(Dexpace::CancelledError) { sync.call(:request, nil, source.token) } + + assert_equal(:interrupted, error.reason) + assert_predicate(transport.completer.future, :cancelled?, + "the in-flight future was cancelled, not orphaned",) + canceller.join + end + + test "threads the exact options object through" do + options = Dexpace::RequestOptions::EMPTY + transport = FakeAsyncTransport.new(response: :ok) + Dexpace::AsyncTransport.sync_over(transport).call(:request, options, nil) + + assert_same(options, transport.calls.first[1]) + end + + test "refuses an async transport that does not return a future" do + sync = Dexpace::AsyncTransport.sync_over(->(_r, _o, _c) { :not_a_future }) + + error = assert_raises(Dexpace::SeamError) { sync.call(:request, nil, nil) } + + assert_match(/must return a Dexpace::Async::Future/, error.message) + end + + test "the bridge's product conforms to the sync seam" do + sync = Dexpace::AsyncTransport.sync_over(->(_r, _o, _c) {}) + + assert(Dexpace::Transport.conforms?(sync)) + end + + test "the bridge is closeable, owns nothing, and leaves the wrapped transport usable" do + sync = Dexpace::AsyncTransport.sync_over(FakeAsyncTransport.new(response: :ok)) + + refute_predicate(sync, :owned?) + assert_nil(sync.close) + assert_predicate(sync, :closed?) + assert_nil(sync.close) + assert_equal(:ok, sync.call(:request, nil, nil)) + end + + # Two blocking calls arming the SAME token is the shape this bridge creates whenever a caller + # derives one token per operation and issues two requests under it. A cancellation guard that is + # per-token rather than per-registration arms only the first waiter, and the second blocks + # forever -- a SEAM-18 violation that no single-waiter test can see. The join timeout is the + # assertion: without it the suite hangs instead of failing. + test "two concurrent waits on one token both unblock when it is cancelled" do + source = Dexpace::Cancellation.source + bridges = Array.new(2) do + Dexpace::AsyncTransport.sync_over( + ->(_r, _o, _c) { Dexpace::Async::Completer.new.future }, + ) + end + reasons = ::Queue.new + + threads = bridges.map do |bridge| + ::Thread.new do + bridge.call(:request, nil, source.token) + rescue Dexpace::CancelledError => error + reasons << error.reason + end + end + sleep(0.05) + source.cancel(:stop) + + threads.each do |thread| + assert(thread.join(5), "a waiter never unblocked: SEAM-18's interruption clause is violated") + end + seen = [] + seen << reasons.pop until reasons.empty? + + assert_equal(%i[stop stop], seen) + end +end diff --git a/gems/dexpace-core/test/dexpace/cancellation/source_test.rb b/gems/dexpace-core/test/dexpace/cancellation/source_test.rb new file mode 100644 index 0000000..56b206d --- /dev/null +++ b/gems/dexpace-core/test/dexpace/cancellation/source_test.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# The write side of SEAM-13's token: one frozen Data snapshot swapped under a Thread::Mutex, so +# concurrent cancels elect exactly one winner -- SEAM-9's serialisation rule applied to the same +# snapshot shape the registry uses -- and hooks run outside the lock. +class DexpaceCancellationSourceTest < DexpaceTestCase + test "concurrent cancels elect exactly one winner and one reason" do + source = Dexpace::Cancellation.source + wins = ::Queue.new + + Array.new(16) { |i| ::Thread.new { wins << i if source.cancel(i) } }.each(&:join) + + assert_equal(1, wins.size) + assert_equal(wins.pop, source.reason, "the reason is the winner's") + end + + test "the source and its token agree on cancelled? and reason" do + source = Dexpace::Cancellation.source + + refute_predicate(source, :cancelled?) + assert_nil(source.reason) + assert_nil(source.cancelled_at) + + source.cancel(:why) + + assert_predicate(source, :cancelled?) + assert_predicate(source.token, :cancelled?) + assert_equal(:why, source.reason) + assert_equal(:why, source.token.reason) + assert_kind_of(::Integer, source.cancelled_at) + end + + test "a hook registered after cancellation fires immediately with the reason" do + source = Dexpace::Cancellation.source + source.cancel(:already) + seen = [] + + returned = source.on_cancel { |reason| seen << reason } + + assert_same(source, returned) + assert_equal([:already], seen) + end + + test "hooks run exactly once each, after the state is published" do + source = Dexpace::Cancellation.source + observed = [] + source.on_cancel { |reason| observed << [reason, source.cancelled?] } + source.on_cancel { |reason| observed << [reason, source.reason] } + + source.cancel(:go) + source.cancel(:again) + + assert_equal([[:go, true], %i[go go]], observed) + end + + test "off_cancel withdraws one registration by identity and is idempotent" do + source = Dexpace::Cancellation.source + seen = [] + kept = proc { seen << :kept } + dropped = proc { seen << :dropped } + source.on_cancel(&kept) + source.on_cancel(&dropped) + + assert_same(source, source.off_cancel(dropped)) + source.off_cancel(dropped) + source.cancel(:go) + + assert_equal([:kept], seen) + assert_same(source, source.off_cancel(kept), "a no-op once the list is taken") + end + + test "the token is a token, is frozen, and is the same object on every read" do + source = Dexpace::Cancellation.source + + assert_instance_of(Dexpace::Cancellation, source.token) + assert_predicate(source.token, :frozen?) + assert_same(source.token, source.token) + end + + # The stamp is monotonic and taken at cancel time, which is what a composed token orders by. + test "cancelled_at orders two sources by the time they cancelled" do + earlier = Dexpace::Cancellation.source + later = Dexpace::Cancellation.source + + earlier.cancel(:first) + later.cancel(:second) + + assert_operator(earlier.cancelled_at, :<=, later.cancelled_at) + end +end diff --git a/gems/dexpace-core/test/dexpace/cancellation_test.rb b/gems/dexpace-core/test/dexpace/cancellation_test.rb new file mode 100644 index 0000000..031353a --- /dev/null +++ b/gems/dexpace-core/test/dexpace/cancellation_test.rb @@ -0,0 +1,281 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../test_helper" +require "dexpace" + +# SEAM-11 and SEAM-16's third argument, SEAM-13's contract, SEAM-18's interruption clause and +# SEAM-30's trigger. The state lives on the Source and the token is a facade over one or more of +# them -- the same split as Completer/Future, and for the same reason: Ruby has no package-private +# visibility, so the alternative is a `send` through the boundary this pair exists to draw. +# +# Three classes, one per behaviour group, because Metrics/ClassLength caps a class at 100 lines: +# the token's own contract here, composition in Composition, and registrations in Subscriptions. +class DexpaceCancellationTest < DexpaceTestCase + test "none is a shared token that can never be cancelled" do + assert_same(Dexpace::Cancellation.none, Dexpace::Cancellation.none) + refute_predicate(Dexpace::Cancellation.none, :cancelled?) + assert_nil(Dexpace::Cancellation.none.reason) + assert_nil(Dexpace::Cancellation.none.check!) + end + + test "a source cancels its token once and the first reason wins" do + source = Dexpace::Cancellation.source + seen = [] + source.token.on_cancel { |reason| seen << reason } + + refute_predicate(source.token, :cancelled?) + assert(source.cancel(:deadline)) + refute(source.cancel(:something_else), "cancel is idempotent; the first reason wins") + + assert_predicate(source.token, :cancelled?) + assert_equal(:deadline, source.token.reason) + assert_equal([:deadline], seen) + end + + test "on_cancel fires immediately when the token is already cancelled" do + source = Dexpace::Cancellation.source + source.cancel(:late) + seen = [] + + source.token.on_cancel { |reason| seen << reason } + + assert_equal([:late], seen) + end + + test "check! raises CancelledError carrying the reason object" do + source = Dexpace::Cancellation.source + source.cancel(:stop) + + error = assert_raises(Dexpace::CancelledError) { source.token.check! } + + assert_equal(:stop, error.reason) + end + + test "a deliberate nil reason is still a cancellation" do + source = Dexpace::Cancellation.source + seen = [] + source.token.on_cancel { |reason| seen << reason } + + source.cancel + + assert_predicate(source.token, :cancelled?) + assert_nil(source.token.reason) + assert_equal([nil], seen) + end + + test "every token is frozen, none included, and none's on_cancel is inert" do + assert_predicate(Dexpace::Cancellation.none, :frozen?) + assert_predicate(Dexpace::Cancellation.source.token, :frozen?) + assert_predicate(Dexpace::Cancellation.any(Dexpace::Cancellation.source.token), :frozen?) + + Dexpace::Cancellation.none.on_cancel { flunk("a token with no sources can never fire") } + end + + test "none's on_cancel still answers a handle, so a caller need not special-case it" do + assert_nil(Dexpace::Cancellation.none.on_cancel { flunk("never fires") }.detach) + end + + test "on_cancel without a block is a caller mistake" do + assert_raises(Dexpace::InvalidArgumentError) { Dexpace::Cancellation.source.token.on_cancel } + end + + test "a source's on_cancel requires a block, exactly as its token's does" do + assert_raises(Dexpace::InvalidArgumentError) { Dexpace::Cancellation.source.on_cancel } + end + + test "new is private; .none, .source, .over and .any are the factories" do + refute_respond_to(Dexpace::Cancellation, :new) + assert_respond_to(Dexpace::Cancellation, :none) + assert_respond_to(Dexpace::Cancellation, :source) + assert_respond_to(Dexpace::Cancellation, :over) + assert_respond_to(Dexpace::Cancellation, :any) + end + + # Composition: .any, .over and #merged_with, and the winner rule. + class Composition < DexpaceTestCase + # The reason is the one of the source that cancelled first in TIME, not the first cancelled + # source in list order. `second` is second in the list and first in time, and #reason must + # agree with what #on_cancel was handed -- a composed token that disagrees with its own + # handler is worse than one with no reason at all. + test "any composes tokens and reports the reason of whichever cancelled first in time" do + first = Dexpace::Cancellation.source + second = Dexpace::Cancellation.source + both = Dexpace::Cancellation.any(first.token, second.token) + seen = [] + both.on_cancel { |reason| seen << reason } + + refute_predicate(both, :cancelled?) + second.cancel(:second) + first.cancel(:first) + + assert_predicate(both, :cancelled?) + assert_equal(:second, both.reason) + assert_equal([:second], seen) + raised = assert_raises(Dexpace::CancelledError) { both.check! } + assert_equal(:second, raised.reason) + end + + test "the winner is the source that cancelled first in time, across more than two" do + first, second, third = Array.new(3) { Dexpace::Cancellation.source } + token = Dexpace::Cancellation.any(first.token, second.token, third.token) + seen = [] + token.on_cancel { |reason| seen << reason } + + third.cancel(:third) + second.cancel(:second) + first.cancel(:first) + + assert_equal(:third, token.reason) + assert_equal([:third], seen) + end + + test "sources is protected, so only another token composes on it" do + refute_respond_to(Dexpace::Cancellation.source.token, :sources) + assert_raises(::NoMethodError) { Dexpace::Cancellation.source.token.sources } + end + + test "any of nothing, and any of none, is none" do + assert_same(Dexpace::Cancellation.none, Dexpace::Cancellation.any) + assert_same(Dexpace::Cancellation.none, Dexpace::Cancellation.any(Dexpace::Cancellation.none)) + end + + test "any de-duplicates a source seen through two tokens" do + source = Dexpace::Cancellation.source + seen = [] + token = Dexpace::Cancellation.any(source.token, Dexpace::Cancellation.any(source.token)) + token.on_cancel { |reason| seen << reason } + + source.cancel(:once) + + assert_equal([:once], seen) + end + + test "any refuses anything that is not a token, naming the class" do + error = assert_raises(Dexpace::InvalidArgumentError) { Dexpace::Cancellation.any(:nope) } + + assert_match(/Symbol/, error.message) + end + + # .over is public so phase 5's deadline source can compose here, so it validates like the + # other two composition entry points. Unguarded it builds a token that raises NoMethodError + # from inside #cancelled? later, at a call site with no idea what went wrong. + test "over refuses anything that is not a source, naming the class" do + error = assert_raises(Dexpace::InvalidArgumentError) { Dexpace::Cancellation.over(:nope) } + + assert_match(/Symbol/, error.message) + end + + test "over of nothing is none, and over of a source reads that source" do + source = Dexpace::Cancellation.source + + assert_same(Dexpace::Cancellation.none, Dexpace::Cancellation.over) + refute_predicate(Dexpace::Cancellation.over(source), :cancelled?) + source.cancel(:go) + + assert_predicate(Dexpace::Cancellation.over(source), :cancelled?) + end + + test "merged_with refuses anything that is not a token" do + assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::Cancellation.source.token.merged_with(:nope) + end + end + end + + # Registrations: every block fires, exactly once, detachably, and a raising one drops no other. + class Subscriptions < DexpaceTestCase + # A shared "something already fired" flag would drop every registration after the first, which + # is what makes a second waiter on one token block forever (SEAM-18). + test "every registered block fires, not just the first" do + source = Dexpace::Cancellation.source + seen = [] + source.token.on_cancel { seen << :first } + source.token.on_cancel { seen << :second } + + source.cancel(:go) + + assert_equal(%i[first second], seen) + end + + # The leak has two halves and either alone is enough to retain a response per request, so + # both are asserted here against ONE long-lived source. (1) A token subscribes only when a + # caller registers a callback, so composing costs nothing that outlives the token; subscribing + # at construction to latch the winner retains one closure per composition. (2) A registration + # made by Completer#await is detached when the await ends, so a bounded wait retains nothing + # on an unbounded source; without the detach the closure reaches the Completer and therefore + # the response it settled with. `.any(client_token, per_call_token)` plus + # `value(cancellation:)` is exactly what `.any` is for and what phase 5a's deadline: keyword + # (Task 8) will do on every request. + # + # The second half awaits a future that is NOT yet settled, deliberately: Completer#await + # returns before arming anything when the future has already settled, so a test that awaits a + # settled future passes with the leak fully present. + test "neither composing nor awaiting retains a callback on a long-lived source" do + client = Dexpace::Cancellation.source + + 200.times { Dexpace::Cancellation.any(client.token, Dexpace::Cancellation.source.token) } + + assert_equal(0, client.instance_variable_get(:@hooks).size, + "composition subscribed to a source at construction",) + + completer = Dexpace::Async::Completer.new + token = Dexpace::Cancellation.any(client.token, Dexpace::Cancellation.source.token) + producer = ::Thread.new do + sleep(0.02) + completer.fulfil(Object.new) + end + + completer.future.value(cancellation: token) + producer.join + + assert_equal(0, client.instance_variable_get(:@hooks).size, + "the await armed a hook on a client-lifetime source and never detached it",) + end + + test "on_cancel returns a handle that detaches exactly its own registration" do + source = Dexpace::Cancellation.source + seen = [] + kept = source.token.on_cancel { seen << :kept } + detached = source.token.on_cancel { seen << :detached } + + detached.detach + + assert_nil(detached.detach, "detach is idempotent and returns nil") + source.cancel(:go) + + assert_equal([:kept], seen) + assert_nil(kept.detach, "detaching after the source has cancelled is a no-op") + end + + test "a composed token's handle detaches from every source it observes" do + first = Dexpace::Cancellation.source + second = Dexpace::Cancellation.source + token = Dexpace::Cancellation.any(first.token, second.token) + + token.on_cancel { flunk("detached before either source cancelled") }.detach + first.cancel(:one) + second.cancel(:two) + + assert_equal(0, first.instance_variable_get(:@hooks).size) + assert_equal(0, second.instance_variable_get(:@hooks).size) + end + + # A raising handler must not silence the ones registered after it. That is the same SEAM-18 + # failure as "a second waiter on one token blocks forever", reached from the write side: the + # second waiter's handler is never called at all. + test "one raising handler does not drop the handlers registered after it" do + source = Dexpace::Cancellation.source + seen = [] + source.token.on_cancel { seen << :first } + source.token.on_cancel { raise ::IOError, "a handler blew up" } + source.token.on_cancel { seen << :third } + + assert_raises(::IOError) { source.cancel(:go) } + + assert_equal(%i[first third], seen) + assert_predicate(source, :cancelled?, "the state was published before any handler ran") + assert_equal(:go, source.reason) + end + end +end diff --git a/gems/dexpace-core/test/dexpace/closeable_test.rb b/gems/dexpace-core/test/dexpace/closeable_test.rb new file mode 100644 index 0000000..7b8ebd6 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/closeable_test.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../test_helper" +require "dexpace" + +# SEAM-14, SEAM-25, XCUT-13, XCUT-22. The latch is a boolean flipped under a Thread::Mutex held +# only across the flip: Ruby's Mutex is per-fiber-owned and non-reentrant (verified on 3.2.11, +# 3.4.10 and 4.0.6), so holding it across a #release that may suspend deadlocks two fibers of one +# thread. +class DexpaceCloseableTest < DexpaceTestCase + # An owning-or-borrowing resource that counts its releases. + class Spy + include Dexpace::Closeable + + attr_reader :releases + + def initialize(owned: true) + @releases = 0 + initialize_closeable(owned: owned) + end + + private + + def release + @releases += 1 + end + end + + # A resource whose release fails, for the propagate-once rule. + class Raising + include Dexpace::Closeable + + def initialize + initialize_closeable(owned: true) + end + + private + + def release + raise ::IOError, "the socket is already gone" + end + end + + # An includer that never called #initialize_closeable. + class Forgetful + include Dexpace::Closeable + end + + # An owning includer that never defined #release. + class NoRelease + include Dexpace::Closeable + + def initialize + initialize_closeable(owned: true) + end + end + + test "close releases exactly once under contention" do + spy = Spy.new + + Array.new(16) { ::Thread.new { spy.close } }.each(&:join) + + assert_equal(1, spy.releases) + assert_predicate_by_respond_to(spy, :closed?) + end + + test "a borrowed resource latches but is never released" do + spy = Spy.new(owned: false) + + spy.close + + assert_equal(0, spy.releases) + refute_predicate(spy, :owned?) + assert_predicate(spy, :closed?, "XCUT-22: the component is closed, the resource is not") + end + + test "an owned resource reports so, and is released by the first close only" do + spy = Spy.new + + assert_predicate(spy, :owned?) + refute_predicate(spy, :closed?) + assert_nil(spy.close) + assert_nil(spy.close) + assert_equal(1, spy.releases, "SEAM-25: only the first close releases") + end + + test "a release that raises still leaves the latch flipped and propagates once" do + raising = Raising.new + + assert_raises(::IOError) { raising.close } + + assert_predicate(raising, :closed?) + assert_nil(raising.close, "BODY-27: no second release is attempted") + end + + test "an including class that never initialised the latch fails loudly" do + error = assert_raises(Dexpace::SeamError) { Forgetful.new.close } + + assert_match(/initialize_closeable/, error.message) + assert_raises(Dexpace::SeamError) { Forgetful.new.closed? } + assert_raises(Dexpace::SeamError) { Forgetful.new.owned? } + end + + # NotImplementedError is a ScriptError, not a StandardError: a class that forgot #release is a + # programmer error and must not be swallowed by an ordinary rescue -- the same reasoning as + # phase 1's Dexpace::Builder#build. + test "an owning class that never defined release fails as a programmer error" do + error = assert_raises(::NotImplementedError) { NoRelease.new.close } + + assert_match(/release/, error.message) + end + + test "close_quietly is null-safe and tolerates a resource with no close" do + assert_nil(Dexpace.close_quietly(nil)) + assert_nil(Dexpace.close_quietly(Object.new)) + end + + test "close_quietly closes what it is given and swallows a close failure" do + spy = Spy.new + + Dexpace.close_quietly(spy) + + assert_equal(1, spy.releases) + # The rescued error is dropped until phase 4b (Task 2) supplies the suppressed trail and phase + # 5b (Task 14) the instrumentation diagnostic. Asserted rather than left as a comment, so the + # day a route lands this test is what has to change. + assert_nil(Dexpace.close_quietly(Raising.new)) + end + + # A NotImplementedError or any other non-StandardError is not a close failure to be swallowed. + test "close_quietly does not swallow a programmer error from close" do + assert_raises(::NotImplementedError) { Dexpace.close_quietly(NoRelease.new) } + end + + # Minitest sends past `private`, verified on 5.25.1/3.2.11 and 6.0.0/4.0.6, so visibility is + # asserted with respond_to? and never with assert_predicate (phase 1's finding). + test "release is private and the latch queries are public" do + spy = Spy.new + + refute_respond_to(spy, :release) + assert_respond_to(spy, :close) + assert_respond_to(spy, :closed?) + assert_respond_to(spy, :owned?) + end + + private + + def assert_predicate_by_respond_to(object, name) + assert_respond_to(object, name) + assert(object.public_send(name)) + end +end diff --git a/gems/dexpace-core/test/dexpace/error/cancelled_error_test.rb b/gems/dexpace-core/test/dexpace/error/cancelled_error_test.rb new file mode 100644 index 0000000..88fb04b --- /dev/null +++ b/gems/dexpace-core/test/dexpace/error/cancelled_error_test.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# SEAM-18's interruption clause read as cooperative cancellation (design P2-4). The reason is a +# typed object rather than a message, because XCUT-2 requires timeout and cancellation to be told +# apart out-of-band; RETRY-23/RETRY-24 read the same field in phase 6. +class DexpaceCancelledErrorTest < DexpaceTestCase + test "is caught by rescue Dexpace::Error through Module#===" do + caught = begin + raise Dexpace::CancelledError + rescue Dexpace::Error => error + error + end + + assert_instance_of(Dexpace::CancelledError, caught) + end + + test "carries the cancellation reason as the object it was given" do + reason = Object.new + + assert_same(reason, Dexpace::CancelledError.new(reason).reason) + end + + test "reads without a reason" do + assert_nil(Dexpace::CancelledError.new.reason) + assert_equal("the operation was cancelled", Dexpace::CancelledError.new.message) + end + + test "names the reason in the message when there is one" do + error = Dexpace::CancelledError.new(:deadline) + + assert_equal("the operation was cancelled: deadline", error.message) + end + + # XCUT-4 puts transport errors in Ruby's IOError family. A cancellation is not a transport + # failure, so it deliberately stays outside it (design P2-4). + test "is not in Ruby's IOError family" do + refute_operator(Dexpace::CancelledError, :<, ::IOError) + assert_operator(Dexpace::CancelledError, :<, ::StandardError) + end +end diff --git a/gems/dexpace-core/test/dexpace/error/closed_error_test.rb b/gems/dexpace-core/test/dexpace/error/closed_error_test.rb new file mode 100644 index 0000000..d98d60a --- /dev/null +++ b/gems/dexpace-core/test/dexpace/error/closed_error_test.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# SEAM-15 is a MAY -- "further send calls MAY have undefined behavior ... A port MAY choose a mode +# but SHOULD document it" -- and this port takes it explicitly: a transport that OWNS the resource +# it closed raises this from a later send. Phase 2 ships the class and the rule and no raise site, +# because it ships no owning transport; phase 8's adapters are the first owners. +class DexpaceClosedErrorTest < DexpaceTestCase + test "is caught by rescue Dexpace::Error through Module#===" do + caught = begin + raise Dexpace::ClosedError, "the transport is closed" + rescue Dexpace::Error => error + error + end + + assert_instance_of(Dexpace::ClosedError, caught) + assert_equal("the transport is closed", caught.message) + end + + test "is a StandardError and not a seam-state or argument error" do + assert_operator(Dexpace::ClosedError, :<, ::StandardError) + refute_operator(Dexpace::ClosedError, :<, ::ArgumentError) + refute_operator(Dexpace::ClosedError, :<, Dexpace::SeamError) + end +end diff --git a/gems/dexpace-core/test/dexpace/error/seam_error_test.rb b/gems/dexpace-core/test/dexpace/error/seam_error_test.rb new file mode 100644 index 0000000..52055df --- /dev/null +++ b/gems/dexpace-core/test/dexpace/error/seam_error_test.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# SEAM-5's zero-or-ambiguous-provider failures and design §2.3's version-skew guard raise this +# class. The distinction under test is the one the design draws between a seam in a bad state -- +# which the caller must fix but did not pass in -- and a bad argument, which is phase 1's +# Dexpace::InvalidArgumentError. +class DexpaceSeamErrorTest < DexpaceTestCase + test "is caught by rescue Dexpace::Error through Module#===" do + caught = begin + raise Dexpace::SeamError, "no transport provider is registered" + rescue Dexpace::Error => error + error + end + + assert_instance_of(Dexpace::SeamError, caught) + assert_equal("no transport provider is registered", caught.message) + end + + test "is a StandardError, so an ordinary rescue catches it" do + assert_operator(Dexpace::SeamError, :<, ::StandardError) + end + + # error-handling/5a185ba9 asks for a standard-library exception where one exactly fits, and + # ArgumentError fits a bad argument -- which a missing provider is not. + test "is not an ArgumentError, because nothing was wrong with any argument" do + refute_operator(Dexpace::SeamError, :<, ::ArgumentError) + refute_operator(Dexpace::SeamError, :<, Dexpace::InvalidArgumentError) + end +end diff --git a/gems/dexpace-core/test/dexpace/operation_build_request_test.rb b/gems/dexpace-core/test/dexpace/operation_build_request_test.rb new file mode 100644 index 0000000..374d328 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/operation_build_request_test.rb @@ -0,0 +1,367 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../test_helper" +require "dexpace" + +# SEAM-27, and SEAM-26's "the body is carried, not encoded, by this seam". +# +# The composition is hand-built and is NOT RFC 3986 reference resolution. Verified on 3.2.11 and +# 4.0.6: URI::RFC3986_PARSER.join("https://host/c?sig=1", "/pets") is "https://host/pets" and +# #merge of the same is identical -- both discard the /c base segment and the sig query that +# SEAM-27's own conformance step requires to survive. Design §3.5's "base-URL composition uses +# URI.join/URI#merge" is superseded here (deviation P2-3, +# docs/knowledge/notes/url-and-query-encoding.md); reference resolution keeps its place at REDIR-13 +# in phase 6, where the spelling is URI::RFC3986_PARSER.join because URI.join is cop-banned. +# +# Four classes because Metrics/ClassLength caps one at 100 lines: the composition rules here, +# the four projections plus the path-value property test in Projections, the template property +# test in Templates and the base-URL property test in Bases. +class DexpaceOperationBuildRequestTest < DexpaceTestCase + # The one operation shape every case builds, shared by all three classes. + module Builds + def operation(template: "/pets", projections: {}, method: "GET") + Dexpace::Operation.build(method: method, template: template, projections: projections) + end + + def url_for(subject, base) = subject.build_request(base_url: base).url.to_s + end + include Builds + + test "the specification's own conformance example" do + subject = operation(projections: { limit: [:query, "limit"] }) + + request = subject.build_request(base_url: "https://host/c?sig=abc", inputs: { limit: 1 }) + + assert_equal("https://host/c/pets?sig=abc&limit=1", request.url.to_s) + assert_equal(Dexpace::Method::GET, request.method) + end + + test "a trailing slash normalises to exactly one separator" do + assert_equal("https://host/c/pets", url_for(operation, "https://host/c/")) + assert_equal("https://host/c/pets", url_for(operation, "https://host/c")) + assert_equal("https://host/pets", url_for(operation, "https://host")) + assert_equal("https://host/c/pets", url_for(operation(template: "pets"), "https://host/c///")) + assert_equal("https://host/c/pets", url_for(operation(template: "//pets"), "https://host/c/")) + end + + test "an empty operation path leaves the base untouched" do + subject = operation(template: "", projections: { limit: [:query, "limit"] }) + + request = subject.build_request(base_url: "https://host/c", inputs: { limit: 1 }) + + assert_equal("https://host/c?limit=1", request.url.to_s) + assert_equal("https://host/c/", url_for(operation(template: ""), "https://host/c/")) + end + + test "an existing base query is preserved with the operation query appended after it" do + subject = operation(projections: { limit: [:query, "limit"] }) + + request = subject.build_request(base_url: "https://host/c?sig=abc", inputs: { limit: 2 }) + + assert_equal("https://host/c/pets?sig=abc&limit=2", request.url.to_s) + end + + test "a dangling base separator is dropped" do + subject = operation(projections: { limit: [:query, "limit"] }) + + request = subject.build_request(base_url: "https://host/c?sig=abc&&", inputs: { limit: 2 }) + + assert_equal("https://host/c/pets?sig=abc&limit=2", request.url.to_s) + assert_equal("https://host/c/pets?sig=abc", url_for(operation, "https://host/c?sig=abc&")) + end + + test "a base with no query and an operation with none yields no query at all" do + assert_equal("https://host/pets", operation.build_request(base_url: "https://host").url.to_s) + end + + test "a base carrying a fragment is rejected with a context-bearing error" do + error = assert_raises(Dexpace::InvalidArgumentError) do + operation.build_request(base_url: "https://host/c#section") + end + + assert_match(/fragment/, error.message) + assert_match(%r{https://host/c\#section}, error.message) + end + + test "a malformed or relative base is rejected through phase 1's URL.parse!" do + assert_raises(Dexpace::InvalidArgumentError) { operation.build_request(base_url: "/c") } + assert_raises(Dexpace::InvalidArgumentError) { operation.build_request(base_url: "ht tp://x") } + assert_raises(Dexpace::InvalidArgumentError) { operation.build_request(base_url: nil) } + end + + # An opaque URI is absolute, so URL.parse! admits it, and it carries no fragment; without its + # own check URI::Generic#path= leaks a stdlib "path conflicts with opaque" from the composition. + test "a base with no hierarchical part is rejected with a context-bearing error" do + %w[mailto:x@y urn:isbn:123].each do |base| + error = assert_raises(Dexpace::InvalidArgumentError, base) do + operation.build_request(base_url: base) + end + + assert_kind_of(Dexpace::Error, error, base) + assert_match(/hierarchical/, error.message, base) + assert_includes(error.message, base) + end + end + + # URL.parse! goes through URI::Generic#query=, whose percent check is /(%\H\H)/ -- a "%" + # followed by two NON-hex characters -- so a query ending in a bare "%" or in "%z" is accepted + # at parse time; appending "&limit=1" then puts "%&l" in front of the same check, and the + # composition leaks a stdlib URI::InvalidURIError. The base's query is validated here against + # RFC 3986 `query` (every character a pchar, "/" or "?", every "%" a two-hex escape), naming + # the base, whether or not an operation query is appended: a base that is malformed on its + # own must not compose into a malformed URL silently either. + test "a base whose query is not RFC 3986 is rejected with a context-bearing error" do + subject = operation(projections: { limit: [:query, "limit"] }) + + ["https://host/c?sig=100%", "https://host/c?;~%", "https://host/c?a=%z", + "https://host/c?a=[1]",].each do |base| + error = assert_raises(Dexpace::InvalidArgumentError, base) do + subject.build_request(base_url: base, inputs: { limit: 1 }) + end + + assert_kind_of(Dexpace::Error, error, base) + assert_match(/query/, error.message, base) + assert_includes(error.message, base) + assert_raises(Dexpace::InvalidArgumentError, base) { subject.build_request(base_url: base) } + end + assert_equal("https://host/c/pets?sig=100%25&limit=1", + subject.build_request(base_url: "https://host/c?sig=100%25", inputs: { limit: 1 }) + .url.to_s,) + end + + test "already-encoded octets in the base survive the composition verbatim" do + request = operation.build_request(base_url: "https://host/a%2Fb?x=%26") + + assert_equal("https://host/a%2Fb/pets?x=%26", request.url.to_s) + end + + test "the composed URL re-parses to itself" do + request = operation.build_request(base_url: "https://host/c") + + assert_equal(request.url.to_s, Dexpace::URL.parse!(request.url.to_s).to_s) + end + + test "the composed URL is frozen and the base is untouched" do + base = Dexpace::URL.parse!("https://host/c") + + request = operation.build_request(base_url: base) + + assert_predicate(request.url, :frozen?) + assert_equal("https://host/c", base.to_s) + end + + # The four projections, and the property test over path values. + class Projections < DexpaceTestCase + include Builds + + # The seam's whole security property, and it gets its own test rather than riding on a + # composition assertion. + test "a path value containing a slash is encoded, not split into segments" do + subject = operation(template: "/pets/{id}", projections: { id: [:path, "id"] }) + + request = subject.build_request(base_url: "https://host", inputs: { id: "a/b" }) + + assert_equal("https://host/pets/a%2Fb", request.url.to_s) + assert_equal(3, request.url.path.split("/").length, "one segment, not two") + end + + test "a path value's reserved characters are all percent-encoded" do + subject = operation(template: "/pets/{id}", projections: { id: [:path, "id"] }) + + request = subject.build_request(base_url: "https://host", inputs: { id: "a b?c#d&e" }) + + assert_equal("https://host/pets/a%20b%3Fc%23d%26e", request.url.to_s) + end + + test "a missing placeholder value is loud and names the input key and the placeholder" do + subject = operation(template: "/pets/{id}", projections: { id: [:path, "id"] }) + + error = assert_raises(Dexpace::InvalidArgumentError) do + subject.build_request(base_url: "https://host") + end + + assert_match(/:id/, error.message) + assert_match(/"id"/, error.message) + end + + # A supplied nil is missing, not empty: SEAM-27 makes a placeholder with no value an error, + # and an `inputs.key?` check alone would render "https://host/pets/" -- well-formed, wrong, + # silent. + test "a nil path input is missing, not an empty segment" do + subject = operation(template: "/pets/{id}", projections: { id: [:path, "id"] }) + + error = assert_raises(Dexpace::InvalidArgumentError) do + subject.build_request(base_url: "https://host", inputs: { id: nil }) + end + + assert_match(/:id/, error.message) + end + + test "false is a path value, not a missing one" do + subject = operation(template: "/pets/{id}", projections: { id: [:path, "id"] }) + + request = subject.build_request(base_url: "https://host", inputs: { id: false }) + + assert_equal("https://host/pets/false", request.url.to_s) + end + + # Phase 1's Query::Builder#add coerces String, Symbol, Integer, Float, true and false through + # #to_s and nil to "", and raises for a Hash or an Array. A generated client reaching for an + # OpenAPI deepObject or a non-default style must fail here rather than render + # "{:color=>\"red\"}" into the query -- a URL that is well-formed, wrong, and silent, which is + # the same failure mode the nil-path-input test guards on the path side. + test "a structured query value is refused, not rendered as an inspect string" do + subject = operation(projections: { filter: [:query, "filter"] }) + + assert_raises(Dexpace::InvalidArgumentError) do + subject.build_request(base_url: "https://host", inputs: { filter: { color: "red" } }) + end + end + + test "a repeated query projection emits one parameter per value" do + subject = operation(projections: { tag: [:query, "tag"] }) + + request = subject.build_request(base_url: "https://host", inputs: { tag: %w[a b] }) + + assert_equal("https://host/pets?tag=a&tag=b", request.url.to_s) + end + + # A nil query input is absent, exactly as an unsupplied one is -- the path side makes nil an + # error because a placeholder cannot be left out, and a query parameter can. HTTP-28's + # value-less parameter is reached with an empty string, which is a value. + test "an absent or nil optional query input contributes nothing; an empty one is value-less" do + subject = operation(projections: { tag: [:query, "tag"] }) + + assert_equal("https://host/pets", subject.build_request(base_url: "https://host").url.to_s) + assert_equal("https://host/pets", + subject.build_request(base_url: "https://host", inputs: { tag: nil }).url.to_s,) + assert_equal("https://host/pets?tag=", + subject.build_request(base_url: "https://host", inputs: { tag: "" }).url.to_s,) + end + + test "header projections go through the outbound header builder" do + subject = operation(projections: { trace: [:header, "X-Trace"] }) + + request = subject.build_request(base_url: "https://host", inputs: { trace: "abc" }) + + assert_equal(["abc"], request.headers["X-Trace"]) + assert_equal(:outbound, request.headers.direction) + end + + # The header side takes the query side's reading of nil, not the path side's: a header can be + # left out where a placeholder cannot, and a generated client passing an unset optional header + # as nil must not send `X-Trace:` with an empty value. An empty String is a value and goes out. + test "an absent or nil optional header input contributes nothing; an empty one is sent" do + subject = operation(projections: { trace: [:header, "X-Trace"] }) + + unset = subject.build_request(base_url: "https://host") + nil_input = subject.build_request(base_url: "https://host", inputs: { trace: nil }) + empty = subject.build_request(base_url: "https://host", inputs: { trace: "" }) + + refute_includes(unset.headers, "X-Trace") + refute_includes(nil_input.headers, "X-Trace") + assert_equal([""], empty.headers["X-Trace"]) + end + + test "a header projection carrying a CRLF is rejected by phase 1's validation, here" do + subject = operation(projections: { trace: [:header, "X-Trace"] }) + + assert_raises(Dexpace::InvalidArgumentError) do + subject.build_request(base_url: "https://host", inputs: { trace: "a\r\nInjected: yes" }) + end + end + + test "the body is carried, not encoded" do + subject = operation(method: "POST", projections: { payload: [:body, "body"] }) + payload = Object.new + + request = subject.build_request(base_url: "https://host", inputs: { payload: payload }) + + assert_same(payload, request.body, "SEAM-26: encoding it is the codec's job at a later stage") + assert_nil(subject.build_request(base_url: "https://host").body) + end + + test "the assembled request meets phase 1's cross-field rules" do + subject = operation(method: "GET", projections: { payload: [:body, "body"] }) + + assert_raises(Dexpace::InvalidArgumentError) do + subject.build_request(base_url: "https://host", inputs: { payload: "x" }) + end + end + + # testing/f36a19cd makes a property-style test mandatory for a value object with a + # parse-constructor invariant. Phase 0's #sample(count:, seed:) is the bounded helper. + test "any path value produces a URL that re-parses with the expected segment count" do + subject = operation(template: "/pets/{id}", projections: { id: [:path, "id"] }) + alphabet = ["a", "/", "?", "#", "&", " ", "%", "\xC3\xA5".b, "\xFF".b].freeze + + sample(count: 40, seed: 20_260_907) do |random| + value = Array.new(random.rand(1..6)) { alphabet.sample(random: random) }.join + url = subject.build_request(base_url: "https://host/c", inputs: { id: value }).url + + assert_equal(url.to_s, Dexpace::URL.parse!(url.to_s).to_s, "value #{value.inspect}") + # The base contributes /c, the template /pets/{id}, and a correctly encoded value adds + # exactly one segment however many slashes it contains: "", "c", "pets", . + assert_equal(4, url.path.split("/").length, + "value #{value.inspect} produced #{url.path.inspect}",) + end + end + end + + # The template literal, from the composition's side. + class Templates < DexpaceTestCase + include Builds + + # The literal's twin of the path-value property in Projections, and the guard on the class + # of leak the construction-time check closes: whatever the template, either .build refuses + # it as the SDK's argument error or #build_request composes a URL that re-parses to itself + # -- no stdlib URI error escapes from either. Only Dexpace::InvalidArgumentError is rescued, + # so a URI::InvalidComponentError from the composition fails the test as an error. + test "any template either fails construction as the SDK's error or composes cleanly" do + alphabet = ["a", "/", "?", "#", "%", "2", "F", " ", "{", "}", "~", ":", "\xC3\xBC"].freeze + + sample(count: 200, seed: 20_260_915) do |random| + template = Array.new(random.rand(0..8)) { alphabet.sample(random: random) }.join + begin + subject = operation(template: template) + rescue Dexpace::InvalidArgumentError + next + end + url = subject.build_request(base_url: "https://host/c?sig=1").url + + assert_equal(url.to_s, Dexpace::URL.parse!(url.to_s).to_s, "template #{template.inspect}") + end + end + end + + # The base URL, from the composition's side: the input the template property above holds + # fixed at "https://host/c?sig=1". + class Bases < DexpaceTestCase + include Builds + + # Whatever the base query, either #build_request refuses the base as the SDK's argument error + # -- URL.parse!'s or the composition's own -- or the composition yields a URL that re-parses + # to itself; no stdlib URI error escapes. The operation query is non-empty because that is + # the append that turns a parse!-accepted "...%" into URI::Generic#query='s "invalid percent + # escape". Only Dexpace::InvalidArgumentError is rescued, so a leaking URI::Error fails the + # test as an error. + test "any base either fails as the SDK's error or composes cleanly" do + subject = operation(projections: { limit: [:query, "limit"] }) + alphabet = ["a", "/", "?", "#", "%", "2", "F", "&", "=", ";", "~", "[", "]", " ", "\xC3\xBC"] + alphabet.freeze + + sample(count: 200, seed: 20_260_915) do |random| + query = Array.new(random.rand(0..8)) { alphabet.sample(random: random) }.join + base = "https://host/c?#{query}" + begin + url = subject.build_request(base_url: base, inputs: { limit: 1 }).url + rescue Dexpace::InvalidArgumentError + next + end + + assert_equal(url.to_s, Dexpace::URL.parse!(url.to_s).to_s, "base #{base.inspect}") + end + end + end +end diff --git a/gems/dexpace-core/test/dexpace/operation_test.rb b/gems/dexpace-core/test/dexpace/operation_test.rb new file mode 100644 index 0000000..d1477a5 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/operation_test.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../test_helper" +require "dexpace" + +# SEAM-26. A frozen Data descriptor: an HTTP method, a path template with named placeholders, and +# typed projections of inputs onto path / query / header / body. Only method and template are +# required and the four projections default to empty, so "a parameterless GET overriding only +# method+path" is the default construction. +# +# Two placeholder checks at two times, which is what makes SEAM-27's "every placeholder MUST have a +# supplied value" structural rather than a runtime hope: at construction the set of :path +# projection names must equal the set of {name} placeholders, and at #build_request every projected +# path input must have a value. +class DexpaceOperationTest < DexpaceTestCase + test "a parameterless operation needs only a method and a template" do + operation = Dexpace::Operation.build(method: "GET", template: "/pets") + + assert_equal(Dexpace::Method.of("GET"), operation.method) + assert_equal("/pets", operation.template) + assert_empty(operation.projections) + assert_empty(operation.placeholders) + end + + test "the method is coerced through Method.of, so a String never survives as a member" do + operation = Dexpace::Operation.build(method: "post", template: "/pets") + + assert_instance_of(Dexpace::Method, operation.method) + assert_equal("POST", operation.method.to_s) + typed = Dexpace::Operation.build(method: Dexpace::Method::GET, template: "/x") + + assert_same(Dexpace::Method::GET, typed.method) + end + + test "a missing required field fails with SEAM-29's message form" do + error = assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::Operation.build(method: "GET", template: nil) + end + + assert_equal("template is required", error.message) + + method_error = assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::Operation.build(method: nil, template: "/pets") + end + + assert_equal("method is required", method_error.message) + end + + test "an empty template is legal, because SEAM-27 says an empty path leaves the base untouched" do + operation = Dexpace::Operation.build(method: "GET", template: "") + + assert_equal("", operation.template) + end + + test "a template must be a String" do + error = assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::Operation.build(method: "GET", template: :pets) + end + + assert_match(/template must be a String/, error.message) + end + + test "placeholders are read off the template, in order, and placeholders_in is public" do + operation = Dexpace::Operation.build( + method: "GET", template: "/owners/{owner}/pets/{id}", + projections: { owner: [:path, "owner"], id: [:path, "id"] }, + ) + + assert_equal(%w[owner id], operation.placeholders) + assert_equal(%w[a b], Dexpace::Operation.placeholders_in("/{a}/{b}")) + assert_empty(Dexpace::Operation.placeholders_in("/none")) + end + + test "every template placeholder must have a path projection" do + error = assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::Operation.build(method: "GET", template: "/pets/{id}") + end + + assert_match(/"id"/, error.message) + assert_match(/no path projection/, error.message) + end + + test "a path projection naming no placeholder is refused" do + error = assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::Operation.build( + method: "GET", template: "/pets", projections: { id: [:path, "id"] }, + ) + end + + assert_match(/name no template placeholder/, error.message) + end + + test "an unbalanced brace and an empty placeholder are refused" do + assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::Operation.build(method: "GET", template: "/pets/{id") + end + assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::Operation.build(method: "GET", template: "/pets/}") + end + assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::Operation.build(method: "GET", template: "/pets/{}") + end + end + + # The literal text between placeholders is copied onto the wire as it stands, so it is checked + # against RFC 3986 `path` here, at construction, rather than surfacing as + # URI::InvalidComponentError -- a stdlib error, outside `rescue Dexpace::Error` -- from + # URI::Generic#path= at the first #build_request (SEAM-27's "resolving to a malformed URL is + # rejected with a context-bearing error"). A literal query is the likeliest generator mistake. + test "a template literal that is not a URI path is refused at construction, naming it" do + ["/x?y", "/x#f", "/a b", "/pets/ü", "/100%", "/%2", "/%G1", "/a(_object) { true }) + Dexpace::Registry.new( + seam: "transport", + installer: "Dexpace::Transport.install", + conforms: conforms, + ) + end + end + include Builds + + test "zero candidates raise a descriptive error naming no gem" do + error = assert_raises(Dexpace::SeamError) { registry.resolve } + + assert_match(/no transport provider is registered/, error.message) + assert_match(/Dexpace::Transport\.install/, error.message) + refute_match(%r{net_http|async_http|net/http|json|oj|excon|typhoeus}i, error.message, + "SEAM-2: core names no concrete implementation, not even in the error path",) + end + + test "exactly one candidate resolves silently and is memoised" do + subject = registry + built = 0 + subject.register(:fake, lambda { + built += 1 + Object.new + }, core: CORE,) + + first = subject.resolve + + assert_same(first, subject.resolve, "SEAM-7: a successful resolution is cached process-wide") + assert_equal(1, built) + end + + test "two candidates raise an error listing every registered key" do + subject = registry + subject.register(:alpha, -> { :alpha }, core: CORE) + subject.register(:beta, -> { :beta }, core: CORE) + + error = assert_raises(Dexpace::SeamError) { subject.resolve } + + assert_match(/:alpha/, error.message) + assert_match(/:beta/, error.message) + end + + test "a failed resolution memoises nothing and a later registration takes effect" do + subject = registry + + assert_raises(Dexpace::SeamError) { subject.resolve } + + subject.register(:late, -> { :late }, core: CORE) + + assert_equal(:late, subject.resolve, "SEAM-7: an unresolved state stays re-evaluable") + end + + test "re-registering the identical factory under one key is a no-op" do + subject = registry + factory = -> { :provider } + subject.register(:key, factory, core: CORE) + + assert_same(subject, subject.register(:key, factory, core: CORE)) + assert_equal([:key], subject.registered_keys) + end + + test "a different factory under an occupied key raises naming both" do + subject = registry + subject.register(:key, -> { :first }, core: CORE) + + error = assert_raises(Dexpace::InvalidArgumentError) do + subject.register(:key, -> { :second }, core: CORE) + end + + assert_match(/already registered/, error.message) + assert_equal([:key], subject.registered_keys, "no partial side effect") + assert_equal(:first, subject.resolve) + end + + test "a class factory is instantiated and a callable factory is called" do + subject = registry + klass = Class.new + subject.register(:key, klass, core: CORE) + + assert_instance_of(klass, subject.resolve) + end + + test "a factory whose product does not implement the seam fails loudly at resolve" do + subject = registry(conforms: ->(object) { object.is_a?(Symbol) }) + subject.register(:key, -> { Object.new }, core: CORE) + + error = assert_raises(Dexpace::SeamError) { subject.resolve } + + assert_match(/does not implement the seam/, error.message) + end + + test "seam and registered_keys read back; registered_keys is a fresh list" do + subject = registry + subject.register(:key, -> { :p }, core: CORE) + + assert_equal("transport", subject.seam) + assert_equal([:key], subject.registered_keys) + refute_same(subject.registered_keys, subject.registered_keys) + end + + # The runtime half of the #call duck type both transport seams share (SEAM-11, SEAM-16): what + # Transport.conforms? and AsyncTransport.conforms? are made of. + class Callable < DexpaceTestCase + test "callable? accepts every callable shape that can take three positional arguments" do + assert(Dexpace::Registry.callable?(->(_a, _b, _c) {}, arity: 3)) + assert(Dexpace::Registry.callable?(proc { |_a, _b, _c| }, arity: 3)) + assert(Dexpace::Registry.callable?(->(*) {}, arity: 3)) + assert(Dexpace::Registry.callable?(->(_a, _b = 1, _c = 2) {}, arity: 3)) + refute(Dexpace::Registry.callable?(->(_a) {}, arity: 3)) + refute(Dexpace::Registry.callable?(->(_a, _b, _c, _d) {}, arity: 3)) + refute(Dexpace::Registry.callable?(Object.new, arity: 3)) + refute(Dexpace::Registry.callable?(nil, arity: 3)) + end + + # "Admits exactly `arity` positional arguments" has to mean the call succeeds with exactly + # those and nothing else. A required keyword makes that call ArgumentError: missing keyword, + # so a near-miss with three positionals and a `must:` would pass registration and fail at the + # first send -- the outcome the predicate exists to prevent, and a stdlib error where the seam + # promises InvalidArgumentError. An optional keyword, a keyword rest and a block parameter all + # leave the positional call intact and stay admitted. + test "callable? refuses a required keyword and admits the optional keyword shapes" do + keyword_object = Class.new { def call(_request, _options, _cancellation, must:) = must }.new + + refute(Dexpace::Registry.callable?(->(_a, _b, _c, must:) {}, arity: 3)) + refute(Dexpace::Registry.callable?(->(*, must:) {}, arity: 3)) + refute(Dexpace::Registry.callable?(keyword_object, arity: 3)) + assert(Dexpace::Registry.callable?(->(_a, _b, _c, may: nil) {}, arity: 3)) + assert(Dexpace::Registry.callable?(->(_a, _b, _c, **) {}, arity: 3)) + assert(Dexpace::Registry.callable?(->(_a, _b, _c, &_block) {}, arity: 3)) + end + + # An object whose #call comes from method_missing has no introspectable parameters and is + # admitted rather than refused, because refusing it would reject a legitimate delegator. So is + # one that answers respond_to?(:call) without respond_to_missing?, whose #method raises + # NameError: the predicate admits what it cannot read rather than refusing it. + test "callable? admits a call reached through method_missing or an unreadable one" do + ghost = Class.new do + def respond_to_missing?(name, include_private = false) = name == :call || super + def method_missing(name, *args) = name == :call ? args : super + end.new + liar = Class.new do + def respond_to?(name, *) = name == :call || super + end.new + + assert(Dexpace::Registry.callable?(ghost, arity: 3)) + assert(Dexpace::Registry.callable?(liar, arity: 3)) + end + end + + # SEAM-6's conflict rule and SEAM-8's warning, by the prior-state table design §3.6 fixes. + class Installation < DexpaceTestCase + include Builds + + test "installing into an empty slot succeeds silently" do + subject = registry + provider = Object.new + + warnings = WarningCapture.record { subject.install(provider) } + + assert_empty(warnings) + assert_same(provider, subject.resolve) + end + + test "installing the identical instance twice is a no-op" do + subject = registry + provider = Object.new + subject.install(provider) + + warnings = WarningCapture.record { subject.install(provider) } + + assert_empty(warnings) + assert_same(provider, subject.resolve) + end + + test "installing a different instance over an explicit install raises naming both" do + subject = registry + incumbent = Object.new + subject.install(incumbent) + + error = assert_raises(Dexpace::InvalidArgumentError) { subject.install(Object.new) } + + assert_match(/already installed/, error.message) + assert_same(incumbent, subject.resolve, "no partial side effect") + end + + # SEAM-8's negative clause -- "no warning is warranted ... when the resolved provider was + # never actually returned" -- holds **vacuously in production** here, because #resolve hands + # out the provider in the same call that resolves it, so an auto-resolved-but-never-handed-out + # slot is unreachable through the ordinary API. The unchecked swap seam SEAM-6 sanctions is the + # only door into that state, which is what this exercises: the branch exists, is correct, and + # would become live the day a non-delivering resolution path is added. It does not prove that + # any production path reaches it, and must not be read as proving that. + test "replacing a swapped-in provider that was never handed out is silent" do + subject = registry + + subject.swap(:auto) do + warnings = WarningCapture.record { subject.install(Object.new) } + + assert_empty(warnings) + end + end + + test "replacing an auto-resolved provider that was already handed out warns" do + subject = registry + subject.register(:key, -> { :auto }, core: CORE) + subject.resolve + + warnings = WarningCapture.record { subject.install(Object.new) } + + assert_equal(1, warnings.size) + assert_match(/already been handed out/, warnings.first) + end + + test "an explicit install wins over a registered factory and is never scanned past" do + subject = registry + subject.register(:key, -> { flunk("SEAM-5: an installed provider always wins") }, core: CORE) + provider = Object.new + + subject.install(provider) + + assert_same(provider, subject.resolve) + end + + test "install refuses a provider that does not implement the seam" do + subject = registry(conforms: ->(object) { object.is_a?(Symbol) }) + + error = assert_raises(Dexpace::InvalidArgumentError) { subject.install(Object.new) } + + assert_match(/must implement the seam/, error.message) + end + end + + # SEAM-9's three clauses, and the re-entrancy cases a "build under the lock" shape gets wrong. + class Concurrency < DexpaceTestCase + include Builds + + test "a concurrent first access runs the discovery scan exactly once" do + subject = registry + built = 0 + counter = ::Thread::Mutex.new + subject.register(:key, lambda { + counter.synchronize { built += 1 } + Object.new + }, core: CORE,) + results = ::Queue.new + + Array.new(32) { ::Thread.new { results << subject.resolve } }.each(&:join) + + resolved = [] + resolved << results.pop until results.empty? + + assert_equal(1, resolved.map(&:object_id).uniq.size, "SEAM-9: exactly-once resolution") + assert_equal(1, built) + end + + # The single-flight gate from the waiter's side: a second resolver arriving while the first is + # still building parks on the claim's Thread::Queue and receives the very instance the winner + # built, with the factory called once. The 32-thread test above cannot promise a waiter ever + # parks -- under the GVL the winner may finish before anyone else reaches the claim -- so the + # factory is held open here until the second resolver is known to be waiting. + test "a second resolver parks on the in-flight claim and receives the winner's instance" do + subject = registry + started = ::Queue.new + release = ::Queue.new + built = 0 + subject.register(:key, lambda { + built += 1 + started << :in_factory + release.pop + Object.new + }, core: CORE,) + + winner = ::Thread.new { subject.resolve } + started.pop + waiter = ::Thread.new { subject.resolve } + sleep(0.05) + release << :go + + assert(winner.join(5) && waiter.join(5), "a waiter never woke from the gate") + assert_same(winner.value, waiter.value) + assert_equal(1, built, "SEAM-9: the scan ran once") + end + + test "two concurrent installs cannot both pass the conflict check" do + subject = registry + accepted = ::Queue.new + + Array.new(16) do + ::Thread.new do + subject.install(Object.new) + accepted << 1 + rescue Dexpace::InvalidArgumentError + nil + end + end.each(&:join) + + assert_equal(1, accepted.size, "SEAM-9: writes are serialized") + end + + # Each of these takes the registry's OWN write mutex from inside the factory or the conformance + # predicate, which is what makes them bite: under a "build inside @write.synchronize" shape + # every one raises ThreadError: deadlock; recursive locking on every supported Ruby. A test + # whose factory only calls #registered_keys would pass under that shape too, because + # #registered_keys takes no lock -- and a regression test that passes under the bug is worse + # than no test. + test "a factory that takes the registry's own write lock does not deadlock" do + subject = registry + installed = Object.new + subject.register(:key, lambda { + subject.install(installed) # #install takes @write + Object.new + }, core: CORE,) + + assert_same(installed, subject.resolve, + "an explicit install that lands mid-build wins; the built provider is dropped",) + end + + test "the conformance predicate can take the write lock, so it is not called under it" do + observed = [] + subject = nil + subject = Dexpace::Registry.new( + seam: "transport", + installer: "Dexpace::Transport.install", + conforms: lambda { |_provider| + subject.swap(:probe) { observed << :ran } # #swap takes @write, twice + true + }, + ) + subject.register(:key, -> { Object.new }, core: CORE) + + subject.resolve + + assert_equal([:ran], observed) + end + + test "a factory that resolves another registry does not deadlock" do + inner = registry + inner.register(:inner, -> { :inner_provider }, core: CORE) + outer = registry + outer.register(:outer, -> { inner.resolve }, core: CORE) + + assert_equal(:inner_provider, outer.resolve) + end + end + + # A factory that reaches back into a registry: the three re-entrancy cases, which the + # "resolves ANOTHER registry" test in Concurrency does not reach, and the LoadError case. + class Reentrancy < DexpaceTestCase + include Builds + + # The three re-entrancy cases, which the "resolves ANOTHER registry" test above does not + # reach. A factory that resolves its OWN registry parks on the gate it took itself, and every + # later caller then parks on the same gate: measured, one self-resolving thread plus four + # unrelated resolvers left five hung on every supported Ruby. The shape this replaced raised + # `ThreadError: deadlock; recursive locking` from the offending call, so a silent wedge would + # be a straight regression. + test "a factory that resolves its own registry raises rather than parking on its own gate" do + subject = registry + subject.register(:key, -> { subject.resolve }, core: CORE) + + error = assert_raises(Dexpace::SeamError) { subject.resolve } + + assert_match(/re-entrant transport resolution/, error.message) + end + + test "a cycle of two registries raises rather than hanging both" do + first = registry + second = registry + first.register(:first, -> { second.resolve }, core: CORE) + second.register(:second, -> { first.resolve }, core: CORE) + + assert_raises(Dexpace::SeamError) { first.resolve } + end + + # The claim is released in #complete_resolution's ensure on this path like any other, so the + # registry is re-evaluable for everyone else. The join timeout is the assertion: without it + # the suite hangs instead of failing. + test "a re-entrant resolve leaves the registry re-evaluable for every other caller" do + subject = registry + reentrant = true + subject.register(:key, lambda { + next subject.resolve if reentrant + + :provider + }, core: CORE,) + + assert_raises(Dexpace::SeamError) { subject.resolve } + reentrant = false + + later = ::Thread.new { subject.resolve } + + assert(later.join(5), "a re-entrant resolve wedged the whole registry") + assert_equal(:provider, later.value) + end + + # SEAM-7's "an UNRESOLVED state MUST remain re-evaluable", against the failure a rescue of + # StandardError alone does not cover. A LoadError from an adapter factory that requires its own + # dependency lazily is the realistic case; releasing the claim only on the StandardError path + # leaves a closed gate latched and every later #resolve spinning at 100% CPU with no exception. + # The join timeout is the assertion: without it the suite hangs instead of failing. + test "a factory raising outside StandardError leaves the registry re-evaluable" do + subject = registry + first_attempt = true + subject.register(:key, lambda { + raise ::LoadError, "the adapter's own dependency is missing" if first_attempt + + :recovered + }, core: CORE,) + + assert_raises(::LoadError) { subject.resolve } + first_attempt = false + + retried = ::Thread.new { subject.resolve } + + assert(retried.join(5), "the registry wedged into a spin instead of re-evaluating") + assert_equal(:recovered, retried.value) + end + + # Both conflict messages interpolate #inspect of two user objects. Built under @write, an + # #inspect that reaches back into the registry -- here one that registers a second key, which + # takes the lock -- met `ThreadError: deadlock; recursive locking` instead of the argument + # error, on every supported Ruby; the messages are built after the block, as the warning is. + test "a registration conflict's message is built outside the lock" do + subject = registry + subject.register(:key, -> { :first }, core: CORE) + + error = assert_raises(Dexpace::InvalidArgumentError) do + subject.register(:key, reaching_into(subject), core: CORE) + end + + assert_match(/reaching/, error.message) + assert_equal(%i[key probe], subject.registered_keys.sort) + end + + test "an install conflict's message is built outside the lock" do + subject = registry + subject.install(Object.new) + + error = assert_raises(Dexpace::InvalidArgumentError) { subject.install(reaching_into(subject)) } + + assert_match(/reaching/, error.message) + assert_equal([:probe], subject.registered_keys) + end + + private + + # An object whose #inspect registers a key on `subject`, taking its write lock. + def reaching_into(subject) + reaching = Object.new + reaching.define_singleton_method(:inspect) do + subject.register(:probe, -> { :probe }, core: CORE) + "reaching" + end + reaching + end + end + + # SEAM-6's "separate unchecked/internal swap seam ... for test-scoped overrides". + class Swap < DexpaceTestCase + include Builds + + test "swap is scoped to its block and restores the previous state" do + subject = registry + subject.register(:key, -> { :real }, core: CORE) + + assert_equal(:real, subject.resolve) + + subject.swap(:fake) do |provider| + assert_equal(:fake, provider) + assert_equal(:fake, subject.resolve) + end + + assert_equal(:real, subject.resolve) + end + + test "swap without a block is a caller mistake" do + assert_raises(Dexpace::InvalidArgumentError) { registry.swap(:fake) } + end + + test "swap restores the previous state when the block raises" do + subject = registry + subject.install(:installed) + + assert_raises(::IOError) { subject.swap(:fake) { raise ::IOError, "inside" } } + + assert_equal(:installed, subject.resolve) + end + + # #swap is the seam every adapter suite will reach for, and a resolution can complete inside + # its block. Restoring the pre-block snapshot wholesale would put that resolution's now-closed + # gate back and wedge every later #resolve the same way. + test "swap does not restore a stale resolution claim" do + subject = registry + started = ::Queue.new + release = ::Queue.new + blocking = true + subject.register(:key, lambda { + if blocking + blocking = false + started << :in_factory + release.pop + end + :provider + }, core: CORE,) + + resolver = ::Thread.new { subject.resolve } + started.pop + subject.swap(:fake) do + release << :go + resolver.join + end + + later = ::Thread.new { subject.resolve } + + assert(later.join(5), "swap restored a closed gate and wedged the registry") + assert_equal(:provider, later.value) + end + + # An adapter registers itself as a side effect of being required, and Ruby will not re-run a + # `require`, so a registration reverted by #swap's ensure is gone for the rest of the process. + # `resolved`, `explicit` and `handed_out` ARE restored -- scoping an override is what swap is + # for -- and `factories` is spliced from the live state for the same reason `resolving` is. + test "swap does not revert a registration made inside its block" do + subject = registry + + subject.swap(:fake) do + subject.register(:key, -> { :real }, core: CORE) + + assert_equal(:fake, subject.resolve, "the override is still in force inside the block") + end + + assert_equal([:key], subject.registered_keys, + "swap reverted a require-time registration, which no later require can redo",) + assert_equal(:real, subject.resolve) + end + end +end diff --git a/gems/dexpace-core/test/dexpace/registry_version_test.rb b/gems/dexpace-core/test/dexpace/registry_version_test.rb new file mode 100644 index 0000000..5abdfa9 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/registry_version_test.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../test_helper" +require "dexpace" + +# NFR-14, and SEAM-10's replacement. Postponed by phase 0 to phase 2, and built here: the runtime +# half of design §2.3's version-skew guard, which SEAM-10's vacuity (design §10.9) makes the real +# Ruby risk. The comparison is hand-rolled because Gem is undefined under `ruby --disable-gems` +# (verified on 3.2.11 and 4.0.6), so this suite is the only place Gem::Requirement appears -- in a +# test process where Bundler has already loaded it. +class DexpaceRegistryVersionTest < DexpaceTestCase + def registry + Dexpace::Registry.new( + seam: "transport", + installer: "Dexpace::Transport.install", + conforms: ->(_object) { true }, + ) + end + + def accepts?(requirement) + registry.register(:adapter, -> { :provider }, core: requirement) + true + rescue Dexpace::SeamError + false + end + + # The guard reads Dexpace::VERSION, and while every gem is at 0.0.0 a grid over the requirement + # alone cannot tell `>=` from `==` on the minor -- `~> 0.0` is the only satisfiable form. So the + # running version is swapped for the duration of a block, through remove_const so no + # "already initialized constant" warning is emitted, and restored in the ensure. Test-only. + def with_running_version(version) + original = Dexpace::VERSION + Dexpace.send(:remove_const, :VERSION) + Dexpace.const_set(:VERSION, version) + yield + ensure + Dexpace.send(:remove_const, :VERSION) + Dexpace.const_set(:VERSION, original) + end + + RUNNING = %w[0.0.0 0.1.3 0.2.0 1.0.0 1.2.7 2.3.1].freeze + + test "the hand-rolled comparison agrees with Gem::Requirement over a grid" do + require "rubygems" + + sample(count: 48, seed: 20_260_907) do |random| + running = RUNNING.sample(random: random) + requirement = "~> #{random.rand(0..2)}.#{random.rand(0..3)}" + expected = Gem::Requirement.new(requirement).satisfied_by?(Gem::Version.new(running)) + + with_running_version(running) do + assert_equal(expected, accepts?(requirement), + "#{requirement.inspect} against dexpace-core #{running}",) + end + end + end + + # The grid above is seeded; this is the exhaustive small square, so a comparison right on the + # samples and wrong on a corner -- `~> 0.0` against 0.1.x is the classic -- cannot slip through. + test "every two-segment requirement from 0.0 to 2.3 agrees with Gem::Requirement" do + require "rubygems" + + RUNNING.each do |running| + with_running_version(running) do + (0..2).each do |major| + (0..3).each do |minor| + requirement = "~> #{major}.#{minor}" + expected = Gem::Requirement.new(requirement).satisfied_by?(Gem::Version.new(running)) + + assert_equal(expected, accepts?(requirement), "#{requirement} against #{running}") + end + end + end + end + end + + test "the swapped running version is always restored" do + with_running_version("9.9.9") { assert_equal("9.9.9", Dexpace::VERSION) } + + assert_match(/\A\d+\.\d+\.\d+\z/, Dexpace::VERSION) + refute_equal("9.9.9", Dexpace::VERSION) + end + + test "the current core version satisfies its own major.minor" do + major, minor, = Dexpace::VERSION.split(".", 3) + + assert(accepts?("~> #{major}.#{minor}")) + end + + test "a minor ahead of the running core is skew and is loud" do + major, minor, = Dexpace::VERSION.split(".", 3) + + error = assert_raises(Dexpace::SeamError) do + registry.register(:adapter, -> { :p }, core: "~> #{major}.#{minor.to_i + 1}") + end + + assert_match(/was built against dexpace-core/, error.message) + assert_includes(error.message, Dexpace::VERSION) + assert_match(/:adapter/, error.message) + end + + test "a different major is skew in both directions" do + major, minor, = Dexpace::VERSION.split(".", 3) + + assert_raises(Dexpace::SeamError) do + registry.register(:adapter, -> { :p }, core: "~> #{major.to_i + 1}.#{minor}") + end + assert_raises(Dexpace::SeamError) do + registry.register(:adapter, -> { :p }, core: "~> #{major.to_i + 2}.#{minor}") + end + end + + test "a requirement that is not the two-segment pessimistic form is refused, not reinterpreted" do + [">= 0.1", "~> 0.1.2", "0.1", "~>0", "", "latest", nil, :"~> 0.0"].each do |requirement| + error = assert_raises(Dexpace::InvalidArgumentError) do + registry.register(:adapter, -> { :p }, core: requirement) + end + + assert_match(/two-segment pessimistic requirement/, error.message, requirement.inspect) + end + end + + test "a rejected registration leaves the registry empty" do + subject = registry + + assert_raises(Dexpace::InvalidArgumentError) do + subject.register(:adapter, -> { :p }, core: ">= 0.1") + end + + assert_empty(subject.registered_keys) + end + + test "core: is required, so a skew check is never silently skipped" do + assert_raises(::ArgumentError) { registry.register(:adapter, -> { :p }) } + end +end diff --git a/gems/dexpace-core/test/dexpace/seam_surface_test.rb b/gems/dexpace-core/test/dexpace/seam_surface_test.rb new file mode 100644 index 0000000..8b34ef5 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/seam_surface_test.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../test_helper" +require_relative "../support/inline_executor" +require "dexpace" + +# SEAM-1 and SEAM-2 as properties of the assembled tree rather than of any one file. The three +# mechanised gates phase 0 built cover the dependency half; this covers the part a gate cannot see. +class DexpaceSeamSurfaceTest < DexpaceTestCase + SEAMS = [Dexpace::Transport, Dexpace::AsyncTransport, Dexpace::Serde].freeze + ENTRY_POINTS = %i[conforms? register install resolve registered_keys swap].freeze + # Every concrete implementation SEAM-2 could tempt an error message into naming. + CONCRETE = %r{net_http|async_http|net/http|async-http|\bjson\b|\boj\b|httpx|excon|typhoeus}i + + test "every seam registry starts empty on a bare require" do + SEAMS.each { |seam| assert_empty(seam.registered_keys, "#{seam} is not empty") } + end + + test "no seam's zero-candidate error names a concrete gem" do + SEAMS.each do |seam| + error = assert_raises(Dexpace::SeamError) { seam.resolve } + + refute_match(CONCRETE, error.message, + "SEAM-2: #{seam} names a concrete implementation in its error path",) + end + end + + test "every seam has the same five registry entry points and a conformance predicate" do + SEAMS.each do |seam| + ENTRY_POINTS.each { |name| assert_respond_to(seam, name, "#{seam}.#{name}") } + end + end + + test "core defines no auto-activation hook, deliberately" do + # Design §3.6 permits presence-gated auto-activation for instrumentation only, and phase 2 + # ships no instrumentation seam, so there is nothing to activate. It is recorded under + # docs/first-release.md § What v1 ships without (post-v1; otel is its only sanctioned user), so + # a later phase reading §3.6 does not conclude it was forgotten. + SEAMS.each do |seam| + refute_respond_to(seam, :install_if_present, "#{seam} grew an auto-activation hook") + refute_respond_to(seam, :auto_activate, "#{seam} grew an auto-activation hook") + end + end + + # gates:require_allowlist is the blocking version of this; the assertion here is the narrower + # one the design states -- phase 1's two requires are still the only ones outside + # require_relative, and phase 2 added none. + test "core requires nothing outside its own tree beyond phase 1's two stdlib features" do + requires = Dir.glob(File.expand_path("../../lib/**/*.rb", __dir__)) + .flat_map { |path| File.readlines(path) } + .grep(/^\s*require\s+["']/) + .map { |line| line[/["']([^"']+)["']/, 1] } + .uniq + .sort + + assert_equal(%w[strscan uri], requires, + "SEAM-1: the only non-relative requires in core are phase 1's, both on the " \ + "allowlist; phase 2 added none",) + end + + test "the seam modules expose no instance side to be included by accident" do + SEAMS.each do |seam| + assert_empty(seam.instance_methods(false), "#{seam} is a singleton module, not a mixin") + end + end + + test "both SEAM-18 bridges live under Dexpace::Bridge, not beside an adapter namespace" do + async = Dexpace::Transport.async_over(->(_r, _o, _c) {}, executor: InlineExecutor.new) + sync = Dexpace::AsyncTransport.sync_over(->(_r, _o, _c) {}) + + assert_instance_of(Dexpace::Bridge::AsyncOver, async) + assert_instance_of(Dexpace::Bridge::SyncOver, sync) + refute(Dexpace::Transport.const_defined?(:AsyncOver, false), "deviation P2-1 and P2-13") + refute(Dexpace::AsyncTransport.const_defined?(:SyncOver, false)) + refute(Dexpace::Transport.const_defined?(:Async, false), "deviation P2-1") + end + + # The three registries are three instances of one class, which is what XCUT-23's deterministic + # single-implementation resolution rests on for phase 9. + test "the three seams hold three distinct registries of one class" do + registries = SEAMS.map { |seam| seam.const_get(:REGISTRY, false) } + + assert_equal(3, registries.map(&:object_id).uniq.size) + registries.each { |registry| assert_instance_of(Dexpace::Registry, registry) } + assert_equal(["transport", "async transport", "codec"], registries.map(&:seam)) + end +end diff --git a/gems/dexpace-core/test/dexpace/serde/deserialization_error_test.rb b/gems/dexpace-core/test/dexpace/serde/deserialization_error_test.rb new file mode 100644 index 0000000..686a0b0 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/serde/deserialization_error_test.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# SEAM-21's decode half and SEAM-23's hierarchy: the decode subtype sits under the seam root, is +# caught by rescue Dexpace::Error, and is distinct from the encode subtype so a caller can rescue +# one without the other. +class DexpaceSerdeDeserializationErrorTest < DexpaceTestCase + test "sits under the seam root and the SDK marker" do + assert_operator(Dexpace::Serde::DeserializationError, :<, Dexpace::Serde::Error) + assert_operator(Dexpace::Serde::DeserializationError, :<, Dexpace::Error) + refute_operator(Dexpace::Serde::DeserializationError, :<, ::IOError) + end + + test "is not the encode subtype, so a rescue of one does not catch the other" do + refute_operator(Dexpace::Serde::DeserializationError, :<, Dexpace::Serde::SerializationError) + refute_operator(Dexpace::Serde::SerializationError, :<, Dexpace::Serde::DeserializationError) + + caught = begin + raise Dexpace::Serde::DeserializationError, "malformed" + rescue Dexpace::Serde::SerializationError + flunk("a decode failure must not be caught as an encode failure") + rescue Dexpace::Serde::Error => error + error + end + + assert_instance_of(Dexpace::Serde::DeserializationError, caught) + end +end diff --git a/gems/dexpace-core/test/dexpace/serde/error_test.rb b/gems/dexpace-core/test/dexpace/serde/error_test.rb new file mode 100644 index 0000000..9c42e04 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/serde/error_test.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# SEAM-23. A class root here, inverting phase 1's module root for the SDK-wide Dexpace::Error, and +# the inversion is deliberate (deviation P2-2): phase 1's root is a module because XCUT-4 puts +# transport errors in Ruby's IOError family and single inheritance makes a class root and that +# requirement mutually exclusive. No competing family exists here, because SEAM-20 and SEAM-21 both +# say a genuine stream I/O error propagates unwrapped rather than being reclassified. +class DexpaceSerdeErrorTest < DexpaceTestCase + test "the hierarchy is a class root with encode and decode subtypes" do + assert_operator(Dexpace::Serde::SerializationError, :<, Dexpace::Serde::Error) + assert_operator(Dexpace::Serde::DeserializationError, :<, Dexpace::Serde::Error) + assert_operator(Dexpace::Serde::Error, :<, ::StandardError) + end + + test "every one is caught by rescue Dexpace::Error" do + [Dexpace::Serde::Error, + Dexpace::Serde::SerializationError, + Dexpace::Serde::DeserializationError,].each do |klass| + caught = begin + raise klass, "boom" + rescue Dexpace::Error => error + error + end + + assert_instance_of(klass, caught) + end + end + + test "a serde failure is not in Ruby's IOError family" do + refute_operator(Dexpace::Serde::Error, :<, ::IOError, + "SEAM-20/SEAM-21: a genuine stream I/O error propagates unwrapped instead",) + end + + test "the base type is open for an adapter to subclass" do + subtype = Class.new(Dexpace::Serde::DeserializationError) + + caught = begin + raise subtype, "vendor-specific" + rescue Dexpace::Serde::Error => error + error + end + + assert_instance_of(subtype, caught) + end + + # Adapters raise these from inside a rescue so Ruby sets #cause automatically, rather than + # leaking the backing library's exception type (serde/5821286d). Stated at the seam here, + # asserted per adapter in phases 7 and 8. + test "raising from inside a rescue chains the original as the cause" do + original = ::RuntimeError.new("the backing library said no") + + caught = begin + begin + raise original + rescue ::RuntimeError + raise Dexpace::Serde::DeserializationError, "decode failed" + end + rescue Dexpace::Serde::Error => error + error + end + + assert_same(original, caught.cause) + end + + # Inside `module Dexpace::Serde` a bare `Error` is this class; the SDK root is reached only as + # Dexpace::Error, fully qualified, which is what every core file writes. + test "a bare Error inside the Serde namespace is the seam root, not the SDK root" do + resolved = Dexpace::Serde.module_eval("Error", __FILE__, __LINE__) + + assert_same(Dexpace::Serde::Error, resolved) + refute_same(Dexpace::Error, resolved) + end +end diff --git a/gems/dexpace-core/test/dexpace/serde/serialization_error_test.rb b/gems/dexpace-core/test/dexpace/serde/serialization_error_test.rb new file mode 100644 index 0000000..756d456 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/serde/serialization_error_test.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# SEAM-20's encode half and SEAM-23's hierarchy: the encode subtype sits under the seam root, is +# caught by rescue Dexpace::Error, and chains the backing library's failure as #cause when raised +# from inside its rescue. +class DexpaceSerdeSerializationErrorTest < DexpaceTestCase + test "sits under the seam root and the SDK marker" do + assert_operator(Dexpace::Serde::SerializationError, :<, Dexpace::Serde::Error) + assert_operator(Dexpace::Serde::SerializationError, :<, Dexpace::Error) + refute_operator(Dexpace::Serde::SerializationError, :<, ::IOError) + end + + test "raised from inside a rescue, it chains the original as the cause" do + caught = begin + begin + raise "the encoder refused the value" + rescue ::RuntimeError + raise Dexpace::Serde::SerializationError, "encode failed" + end + rescue Dexpace::Error => error + error + end + + assert_instance_of(Dexpace::Serde::SerializationError, caught) + assert_equal("the encoder refused the value", caught.cause.message) + end +end diff --git a/gems/dexpace-core/test/dexpace/serde_test.rb b/gems/dexpace-core/test/dexpace/serde_test.rb new file mode 100644 index 0000000..31d215c --- /dev/null +++ b/gems/dexpace-core/test/dexpace/serde_test.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../test_helper" +require_relative "../support/fake_codec" +require_relative "../support/incomplete_codec" +require "dexpace" +require "stringio" + +# SEAM-19, SEAM-20, SEAM-21, SEAM-22's surviving clause, SEAM-2. The seam is a duck type of six +# methods: all four of SEAM-20's allocation profiles ship, and two of them are one Ruby type -- a +# String tagged Encoding::BINARY *is* Ruby's byte array, so #dump_bytes differs from #dump_string +# only in the encoding tag, which is the whole of the distinction the requirement draws (§10.13). +class DexpaceSerdeTest < DexpaceTestCase + CORE = "~> 0.0" + + test "conforms? requires all six seam methods and names the missing ones" do + assert(Dexpace::Serde.conforms?(FakeCodec.new)) + refute(Dexpace::Serde.conforms?(IncompleteCodec.new)) + refute(Dexpace::Serde.conforms?(Object.new)) + + assert_equal(%i[dump_into], Dexpace::Serde.missing_methods(IncompleteCodec.new)) + assert_empty(Dexpace::Serde.missing_methods(FakeCodec.new)) + end + + test "the media type is never defaulted at the seam level" do + refute_respond_to(Dexpace::Serde, :media_type, + "SEAM-19: the seam supplies no default and has no fallback to fall back to",) + assert_equal("application/vnd.dexpace.fake", FakeCodec.new.media_type) + end + + test "a codec that forgets media_type fails conformance rather than stamping the wrong type" do + forgetful = Class.new(FakeCodec) do + undef_method :media_type + end.new + + refute(Dexpace::Serde.conforms?(forgetful)) + assert_equal(%i[media_type], Dexpace::Serde.missing_methods(forgetful)) + end + + test "the registry starts empty and its error names this seam and no gem" do + assert_empty(Dexpace::Serde.registered_keys) + + error = assert_raises(Dexpace::SeamError) { Dexpace::Serde.resolve } + + assert_match(/no codec provider is registered/, error.message) + assert_match(/Dexpace::Serde\.install/, error.message) + refute_match(/json|oj/i, error.message, "SEAM-2") + end + + test "install refuses a codec that does not implement the seam" do + error = assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::Serde.install(IncompleteCodec.new) + end + + assert_match(/must implement the seam/, error.message) + end + + test "swap scopes a codec override to its block" do + codec = FakeCodec.new + + Dexpace::Serde.swap(codec) do + assert_same(codec, Dexpace::Serde.resolve) + end + + assert_raises(Dexpace::SeamError) { Dexpace::Serde.resolve } + end + + test "install goes through the module, returns it, and is scoped by an enclosing swap" do + codec = FakeCodec.new + + Dexpace::Serde.swap(:override) do + assert_same(Dexpace::Serde, Dexpace::Serde.install(codec)) + assert_same(codec, Dexpace::Serde.resolve) + end + + assert_raises(Dexpace::SeamError) { Dexpace::Serde.resolve } + end + + # Scoped inside a swap and cleaned out of the private registry afterwards, for the reason + # transport_test.rb gives: registrations are process-global and kept by design. + test "register goes through the module and returns the module" do + Dexpace::Serde.swap(FakeCodec.new) do + assert_same(Dexpace::Serde, Dexpace::Serde.register(:probe, FakeCodec, core: CORE)) + assert_includes(Dexpace::Serde.registered_keys, :probe) + end + ensure + registry = Dexpace::Serde.const_get(:REGISTRY, false) + state = registry.instance_variable_get(:@state) + registry.instance_variable_set(:@state, state.with(factories: state.factories.except(:probe))) + end + + # SEAM-20 and SEAM-21's never-close rule, asserted against the fake so the seam's own harness is + # not the first place it is tried; dexpace-conformance asserts it per adapter. + test "the streaming and buffer variants never close the caller's target" do + codec = FakeCodec.new + sink = StringIO.new(+"") + + codec.dump_to(:payload, sink) + + refute_predicate(sink, :closed?, "SEAM-20: a streaming variant must not close the sink") + + source = StringIO.new("payload") + codec.load(source, ->(text) { text }) + + refute_predicate(source, :closed?, "SEAM-21: decode reads to EOF and does not close the source") + end + + test "dump_bytes and dump_string differ exactly in the encoding tag" do + codec = FakeCodec.new + + assert_equal(Encoding::BINARY, codec.dump_bytes(:payload).encoding) + assert_equal("payload", codec.dump_bytes(:payload).force_encoding(Encoding::UTF_8)) + assert_equal("payload", codec.dump_string(:payload)) + end + + test "dump_into writes at the offset and raises IndexError on overflow" do + buffer = +" " + + assert_equal(7, FakeCodec.new.dump_into(:payload, buffer, offset: 2)) + assert_equal(" payload ", buffer) + assert_raises(::IndexError) do + FakeCodec.new.dump_into(:a_long_payload, +" ", offset: 0) + end + end + + # SEAM-22's mechanism -- a reflective generic type capture -- is replaced by the witness protocol + # (§10.14, §7.3, phase 7). The clause that survives the substitution is fixed here: #load takes an + # explicit witness and there is no witness-less overload. + test "load requires an explicit witness" do + assert_raises(::ArgumentError) { FakeCodec.new.load(StringIO.new("x")) } + end + + test "the seam module has no instance side to include by accident" do + assert_empty(Dexpace::Serde.instance_methods(false)) + end +end diff --git a/gems/dexpace-core/test/dexpace/transport_test.rb b/gems/dexpace-core/test/dexpace/transport_test.rb new file mode 100644 index 0000000..92edc38 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/transport_test.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../test_helper" +require_relative "../support/fake_transport" +require_relative "../support/options_ignoring_transport" +require_relative "../support/inline_executor" +require "dexpace" + +# SEAM-11, SEAM-12, SEAM-13, SEAM-15, SEAM-2. A transport is any object responding to +# #call(request, options, cancellation) and returning a Dexpace::Response. #call is the convergence +# point of Ruby's own middleware ecosystems (porting-method/bf484e8e, P14), so a bare lambda is a +# valid transport and phase 4's Dexpace::Pipeline can stand in wherever one is expected (PIPE-26). +class DexpaceTransportTest < DexpaceTestCase + CORE = "~> 0.0" + + test "conforms? accepts every callable shape that can take the seam's three arguments" do + assert(Dexpace::Transport.conforms?(->(_request, _options, _cancellation) {})) + assert(Dexpace::Transport.conforms?(proc { |_request, _options, _cancellation| })) + assert(Dexpace::Transport.conforms?(->(*) {})) + assert(Dexpace::Transport.conforms?(FakeTransport.new)) + assert(Dexpace::Transport.conforms?(OptionsIgnoringTransport.new(:response))) + end + + test "conforms? refuses the wrong arity and anything that is not callable" do + refute(Dexpace::Transport.conforms?(->(_request) {})) + refute(Dexpace::Transport.conforms?(->(_request, _options, _cancellation, must:) {})) + refute(Dexpace::Transport.conforms?(Object.new)) + refute(Dexpace::Transport.conforms?(nil)) + end + + test "the registry starts empty, so SEAM-1 holds on a bare require" do + assert_empty(Dexpace::Transport.registered_keys) + end + + test "install refuses an object that does not implement the seam" do + error = assert_raises(Dexpace::InvalidArgumentError) { Dexpace::Transport.install(Object.new) } + + assert_match(/must implement the seam/, error.message) + end + + test "swap scopes an override to its block" do + transport = ->(_request, _options, _cancellation) { :response } + + Dexpace::Transport.swap(transport) do + assert_same(transport, Dexpace::Transport.resolve) + end + + assert_raises(Dexpace::SeamError) { Dexpace::Transport.resolve } + end + + # An install inside a swap block is part of the override and is restored with it, which is + # what lets a process-global registry be exercised without leaking into the next test. + test "install goes through the module, returns it, and is scoped by an enclosing swap" do + installed = ->(_request, _options, _cancellation) { :installed } + + Dexpace::Transport.swap(:override) do + assert_same(Dexpace::Transport, Dexpace::Transport.install(installed)) + assert_same(installed, Dexpace::Transport.resolve) + end + + assert_raises(Dexpace::SeamError) { Dexpace::Transport.resolve } + end + + test "register and resolve go through one registry, and the error names no gem" do + error = assert_raises(Dexpace::SeamError) { Dexpace::Transport.resolve } + + assert_match(/no transport provider is registered/, error.message) + refute_match(%r{net_http|async_http|net/http}i, error.message, "SEAM-2") + end + + # The module-level registry is process-global, so the registration is scoped inside a swap and + # its registration is the one thing swap deliberately keeps -- which is why the key is removed + # through the private registry afterwards rather than left for the next test to trip over. + test "register goes through the module and returns the module" do + Dexpace::Transport.swap(:override) do + assert_same(Dexpace::Transport, Dexpace::Transport.register(:probe, -> { :p }, core: CORE)) + assert_includes(Dexpace::Transport.registered_keys, :probe) + end + ensure + forget(Dexpace::Transport, :probe) + end + + test "an options-ignoring transport behaves identically with and without options" do + transport = OptionsIgnoringTransport.new(:response) + populated = Dexpace::RequestOptions.builder.tap do |builder| + builder.timeout = 2.5 + builder.max_retries = 7 + end.build + + refute_equal(Dexpace::RequestOptions::EMPTY, populated, "the two calls really differ") + + assert_equal( + transport.call(:request, Dexpace::RequestOptions::EMPTY, nil), + transport.call(:request, populated, nil), + "SEAM-11: options are inert immutable data, so ignoring them is not reading them", + ) + end + + # SEAM-12 is a property of an implementation and phase 2 ships none; what the seam owes is that + # nothing forces per-request state onto shared storage. The fake keeps every call in a local and + # returns everything it produces, and this is the harness phase 8's adapters inherit. + test "a conforming transport survives concurrent calls with no cross-talk" do + transport = ->(request, _options, _cancellation) { request } + results = ::Queue.new + + Array.new(32) { |i| ::Thread.new { results << transport.call(i, nil, nil) } }.each(&:join) + + seen = [] + seen << results.pop until results.empty? + + assert_equal((0...32).to_a, seen.sort) + end + + test "async_over requires a caller-supplied executor and refuses anything without #post" do + error = assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::Transport.async_over(FakeTransport.new, executor: Object.new) + end + + assert_match(/no default/, error.message, "SEAM-18: there is intentionally no default executor") + end + + test "async_over refuses a non-conforming transport" do + assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::Transport.async_over(Object.new, executor: InlineExecutor.new) + end + end + + test "ClosedError is the documented post-close failure mode" do + assert_operator(Dexpace::ClosedError, :<, ::StandardError) + assert_operator(Dexpace::ClosedError, :<, Dexpace::Error) + end + + private + + # Removes a key from a seam's private registry after a test that had to register one: the + # registry keeps registrations across a swap by design, and the module is process-global. + def forget(seam, key) + registry = seam.const_get(:REGISTRY, false) + state = registry.instance_variable_get(:@state) + registry.instance_variable_set(:@state, state.with(factories: state.factories.except(key))) + end +end diff --git a/gems/dexpace-core/test/dexpace_test.rb b/gems/dexpace-core/test/dexpace_test.rb index b3cd124..71de071 100644 --- a/gems/dexpace-core/test/dexpace_test.rb +++ b/gems/dexpace-core/test/dexpace_test.rb @@ -31,15 +31,21 @@ class DexpaceTest < DexpaceTestCase end # Every public constant the surface manifest records, and the check that catches a file added - # to lib/ and forgotten in the entry point. + # to lib/ and forgotten in the entry point. Phase 1's domain model, then phase 2's seam layer; + # Dexpace::Hooks is a private_constant and does not appear in Dexpace.constants(false). DOMAIN_MODEL = %i[ Error InvalidArgumentError Model Builder HeaderSyntax HeaderName Headers Status Method Protocol MediaType PercentEncoding Query URL RequestOptions Request Response ].freeze + SEAM_LAYER = %i[ + SeamError ClosedError CancelledError Closeable Cancellation Async Registry Bridge Transport + AsyncTransport Serde Operation + ].freeze test "defines nothing outside the Dexpace namespace" do assert_empty(TOP_LEVEL_ADDED - [:Dexpace], "top-level constants added by the entry file") - assert_empty(NAMESPACE_ADDED - [:VERSION, *DOMAIN_MODEL], "constants added under Dexpace") + assert_empty(NAMESPACE_ADDED - [:VERSION, *DOMAIN_MODEL, *SEAM_LAYER], + "constants added under Dexpace",) assert_includes(Dexpace.constants(false), :VERSION) end @@ -52,13 +58,29 @@ class DexpaceTest < DexpaceTestCase end test "every constant the manifest records is reachable from Dexpace" do - DOMAIN_MODEL.each { |name| assert(Dexpace.const_defined?(name, false), "#{name} missing") } - assert_empty(DOMAIN_MODEL - Dexpace.constants(false)) + (DOMAIN_MODEL + SEAM_LAYER).each do |name| + assert(Dexpace.const_defined?(name, false), "#{name} missing") + end + assert_empty((DOMAIN_MODEL + SEAM_LAYER) - Dexpace.constants(false)) %i[Headers Query RequestOptions Request Response].each do |name| assert(Dexpace.const_get(name).const_defined?(:Builder, false), "#{name}::Builder") end end + # A consumer requires "dexpace" and nothing else: the seam layer resolves too (phase 2). + test "requiring dexpace alone makes the whole seam layer resolve" do + assert_equal(Dexpace::Transport, Dexpace.const_get(:Transport)) + assert_equal(Dexpace::AsyncTransport, Dexpace.const_get(:AsyncTransport)) + assert_equal(Dexpace::Serde, Dexpace.const_get(:Serde)) + assert_equal(Dexpace::Operation, Dexpace.const_get(:Operation)) + assert_equal(Dexpace::Registry, Dexpace.const_get(:Registry)) + assert_equal(Dexpace::Cancellation, Dexpace.const_get(:Cancellation)) + assert_equal(Dexpace::Async::Future, Dexpace::Async.const_get(:Future)) + assert_equal(Dexpace::Closeable, Dexpace.const_get(:Closeable)) + refute_includes(Dexpace.constants(false), :Hooks, "Dexpace::Hooks is a private_constant") + assert_raises(::NameError) { Dexpace::Hooks } + end + # The shadowing name this SDK never defines: it would make a bare `rescue ArgumentError` # inside `module Dexpace` stop catching Ruby's own (deviation P1-3). test "never defines Dexpace::ArgumentError" do diff --git a/gems/dexpace-core/test/support/fake_async_transport.rb b/gems/dexpace-core/test/support/fake_async_transport.rb new file mode 100644 index 0000000..cc81e2a --- /dev/null +++ b/gems/dexpace-core/test/support/fake_async_transport.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# The in-memory async fake: implements only SEAM-16, and settles its future synchronously unless a +# `settle_later` block is supplied, so a test can drive the completion race deliberately. +class FakeAsyncTransport + attr_reader :calls, :completer + + def initialize(response: nil, raises: nil, settle_later: false) + @response = response + @raises = raises + @settle_later = settle_later + @calls = [] + end + + def call(request, options, cancellation) + @calls << [request, options, cancellation] + @completer = Dexpace::Async::Completer.new + settle unless @settle_later + @completer.future + end + + def settle + return @completer.fail(@raises) if @raises + + @completer.fulfil(@response) + end +end diff --git a/gems/dexpace-core/test/support/fake_codec.rb b/gems/dexpace-core/test/support/fake_codec.rb new file mode 100644 index 0000000..516c330 --- /dev/null +++ b/gems/dexpace-core/test/support/fake_codec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# A codec that implements the seam and nothing beyond it. It is not a JSON codec and does not +# pretend to be: SEAM-19's point is that a codec declares its own media type, so the fake declares +# an obviously fake one and the test asserts the seam never defaults it. +class FakeCodec + def media_type = "application/vnd.dexpace.fake" + + def dump_string(value) = value.to_s + + def dump_bytes(value) = value.to_s.b + + # SEAM-20: a streaming variant never closes the caller's target. + def dump_to(value, sink) = sink.write(dump_string(value)) + + def dump_into(value, buffer, offset:) + encoded = dump_string(value) + raise ::IndexError, "buffer too small" if offset + encoded.bytesize > buffer.bytesize + + buffer[offset, encoded.bytesize] = encoded + encoded.bytesize + end + + # SEAM-21: reads to EOF, never closes the caller's source, and requires an explicit witness -- + # there is no witness-less overload to fall into (SEAM-22's surviving clause). + def load(source, witness) = witness.call(source.read) +end diff --git a/gems/dexpace-core/test/support/fake_transport.rb b/gems/dexpace-core/test/support/fake_transport.rb new file mode 100644 index 0000000..5712e4b --- /dev/null +++ b/gems/dexpace-core/test/support/fake_transport.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# The in-memory fake the roadmap's cross-cutting constraint 4 requires of phases 1 through 7: +# implements only SEAM-11, holds no socket, and observes the cancellation token at the one point a +# real transport would. +# +# It lives in dexpace-core's test tree and is not public API. A published fake would need a YARD +# block, an RBS mirror and a row in the runtime surface manifest, after which changing its shape +# would be a public API change diffed against a release tag (NFR-4) -- a real cost paid forever for +# a convenience. dexpace-conformance is the gem chartered to publish adapter test doubles (§9.3) +# and it is phase 8's. The move was later declined by phase 8a, on the development-dependency cycle +# it would create, so these fakes stay here. +class FakeTransport + # @return [Array] one [request, options, cancellation] triple per call + attr_reader :calls + + def initialize(response: nil, raises: nil, before_return: nil) + @response = response + @raises = raises + @before_return = before_return + @calls = [] + @mutex = ::Thread::Mutex.new + end + + def call(request, options, cancellation) + @mutex.synchronize { @calls << [request, options, cancellation] } + @before_return&.call + raise @raises if @raises + + @response + end +end diff --git a/gems/dexpace-core/test/support/incomplete_codec.rb b/gems/dexpace-core/test/support/incomplete_codec.rb new file mode 100644 index 0000000..f4eb919 --- /dev/null +++ b/gems/dexpace-core/test/support/incomplete_codec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# A codec missing one method (#dump_into), so the conformance predicate has something to reject +# and Serde.missing_methods something to name. +class IncompleteCodec + def media_type = "application/vnd.dexpace.incomplete" + def dump_string(value) = value.to_s + def dump_bytes(value) = value.to_s.b + def dump_to(value, sink) = sink.write(dump_string(value)) + def load(source, witness) = witness.call(source.read) +end diff --git a/gems/dexpace-core/test/support/inline_executor.rb b/gems/dexpace-core/test/support/inline_executor.rb new file mode 100644 index 0000000..4accfcf --- /dev/null +++ b/gems/dexpace-core/test/support/inline_executor.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# The smallest thing that satisfies the executor duck type SEAM-18 requires the caller to supply: +# an object responding to #post. Running the block inline is what makes the bridge's behaviour +# deterministic in a test; phase 8's dexpace-async-thread supplies a real bounded pool. +class InlineExecutor + def post = yield +end diff --git a/gems/dexpace-core/test/support/options_ignoring_transport.rb b/gems/dexpace-core/test/support/options_ignoring_transport.rb new file mode 100644 index 0000000..e942269 --- /dev/null +++ b/gems/dexpace-core/test/support/options_ignoring_transport.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# A transport that ignores its options entirely, for SEAM-11's "a transport that ignores options +# MUST behave identically to the no-options call". Its own file, because Style/OneClassPerFile +# allows one top-level class per file. +class OptionsIgnoringTransport + def initialize(response) = @response = response + + def call(_request, _options, _cancellation) = @response +end diff --git a/gems/dexpace-core/test/support/probe_scheduler.rb b/gems/dexpace-core/test/support/probe_scheduler.rb new file mode 100644 index 0000000..23dbc89 --- /dev/null +++ b/gems/dexpace-core/test/support/probe_scheduler.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# A minimal Fiber scheduler that records which hooks a blocking operation routed through. It is a +# probe, not an implementation: #block runs other ready fibers in a nested loop rather than +# suspending the calling fiber, which is the shape that stays well-behaved when the blocker is a +# Thread::Mutex. +# +# #fiber_interrupt is defined because Ruby 4.0.6 warns "Scheduler should implement #fiber_interrupt" +# without it, and the shared test case turns a warning into a failure for the test that triggered +# it. Verified on 4.0.6 that defining it silences the warning. +class ProbeScheduler + attr_reader :hooks + + def initialize + @hooks = [] + @ready = [] + @unblocked = {} + end + + # The name and the boolean return are Ruby's Fiber::Scheduler interface, not a choice. + def block(blocker, _timeout = nil) # rubocop:disable Naming/PredicateMethod -- the scheduler hook's own name + @hooks << [:block, blocker.class.name] + me = Fiber.current + until @unblocked.delete(me) + runnable = @ready.shift + return false if runnable.nil? + + runnable.resume if runnable.alive? + end + true + end + + def unblock(blocker, fiber) + @hooks << [:unblock, blocker.class.name] + @unblocked[fiber] = true + @ready << fiber + end + + def kernel_sleep(_duration = nil) = nil + def io_wait(_io, events, _timeout) = events + def fiber_interrupt(_fiber, _exception) = nil + + def fiber(&) + created = Fiber.new(blocking: false, &) + @ready << created + created + end + + def close + until @ready.empty? + runnable = @ready.shift + runnable.resume if runnable.alive? + end + end +end diff --git a/gems/dexpace-core/test/support/warning_capture.rb b/gems/dexpace-core/test/support/warning_capture.rb new file mode 100644 index 0000000..2d45a6d --- /dev/null +++ b/gems/dexpace-core/test/support/warning_capture.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# Records a Kernel#warn instead of letting the shared test case raise on it. +# +# Phase 0's DexpaceTestCase prepends a module to Warning's singleton class that raises, so a +# warning fails the test that triggered it. SEAM-8 requires core to emit a warning, so exactly one +# test needs to observe one without failing. Prepending this module *after* phase 0's puts it ahead +# in the ancestor chain, so a recorded warning never reaches the raiser and an unrecorded one still +# does -- verified on 3.2.11, 3.4.10 and 4.0.6. +# +# This replaces the design's "adds the first entry to phase 0's warning allowlist": it is scoped to +# one block rather than to a message pattern for the life of the suite, and it needs no knowledge +# of phase 0's allowlist API (deviation P2-12). +module WarningCapture + def self.recorded = @recorded ||= [] + + def self.recording? = @recording + + # @return [Array] the warnings emitted inside the block + def self.record + @recording = true + recorded.clear + yield + recorded.dup + ensure + @recording = false + end + + def warn(message, category: nil) + return WarningCapture.recorded << message if WarningCapture.recording? + + super + end +end + +Warning.singleton_class.prepend(WarningCapture) diff --git a/test/fixtures/surface/dexpace-async-thread.txt b/test/fixtures/surface/dexpace-async-thread.txt index 711805c..1af0a49 100644 --- a/test/fixtures/surface/dexpace-async-thread.txt +++ b/test/fixtures/surface/dexpace-async-thread.txt @@ -1,3 +1,2 @@ -Dexpace::Async Dexpace::Async::Thread Dexpace::Async::Thread::VERSION : String diff --git a/test/fixtures/surface/dexpace-core.txt b/test/fixtures/surface/dexpace-core.txt index 6833186..16173f7 100644 --- a/test/fixtures/surface/dexpace-core.txt +++ b/test/fixtures/surface/dexpace-core.txt @@ -1,7 +1,77 @@ Dexpace +Dexpace.close_quietly +Dexpace::Async +Dexpace::Async::Completer +Dexpace::Async::Completer#await +Dexpace::Async::Completer#fail +Dexpace::Async::Completer#fulfil +Dexpace::Async::Completer#future +Dexpace::Async::Completer#on_cancel +Dexpace::Async::Completer#on_settle +Dexpace::Async::Completer#outcome +Dexpace::Async::Completer#request_cancel +Dexpace::Async::Completer#settled? +Dexpace::Async::Future +Dexpace::Async::Future#cancel +Dexpace::Async::Future#cancelled? +Dexpace::Async::Future#on_settle +Dexpace::Async::Future#settled? +Dexpace::Async::Future#then +Dexpace::Async::Future#value +Dexpace::Async::Future#wait +Dexpace::Async::Settlement +Dexpace::Async::Settlement#cancelled +Dexpace::Async::Settlement#error +Dexpace::Async::Settlement#response +Dexpace::Async::Settlement#success? +Dexpace::Async::Settlement.build +Dexpace::Async::Settlement.cancellation +Dexpace::Async::Settlement.failure +Dexpace::Async::Settlement.success +Dexpace::AsyncTransport +Dexpace::AsyncTransport.conforms? +Dexpace::AsyncTransport.install +Dexpace::AsyncTransport.register +Dexpace::AsyncTransport.registered_keys +Dexpace::AsyncTransport.resolve +Dexpace::AsyncTransport.swap +Dexpace::AsyncTransport.sync_over +Dexpace::Bridge +Dexpace::Bridge::AsyncOver +Dexpace::Bridge::AsyncOver#call +Dexpace::Bridge::SyncOver +Dexpace::Bridge::SyncOver#call Dexpace::Builder Dexpace::Builder#build Dexpace::Builder.build_all +Dexpace::Cancellation +Dexpace::Cancellation#cancelled? +Dexpace::Cancellation#check! +Dexpace::Cancellation#merged_with +Dexpace::Cancellation#on_cancel +Dexpace::Cancellation#reason +Dexpace::Cancellation.any +Dexpace::Cancellation.none +Dexpace::Cancellation.over +Dexpace::Cancellation.source +Dexpace::Cancellation::Source +Dexpace::Cancellation::Source#cancel +Dexpace::Cancellation::Source#cancelled? +Dexpace::Cancellation::Source#cancelled_at +Dexpace::Cancellation::Source#off_cancel +Dexpace::Cancellation::Source#on_cancel +Dexpace::Cancellation::Source#reason +Dexpace::Cancellation::Source#token +Dexpace::Cancellation::Subscription +Dexpace::Cancellation::Subscription#detach +Dexpace::CancelledError +Dexpace::CancelledError#reason +Dexpace::Closeable +Dexpace::Closeable#close +Dexpace::Closeable#closed? +Dexpace::Closeable#initialize_closeable +Dexpace::Closeable#owned? +Dexpace::ClosedError Dexpace::Error Dexpace::HeaderName Dexpace::HeaderName#== @@ -88,6 +158,14 @@ Dexpace::Model#with Dexpace::Model.frozen_string Dexpace::Model.own Dexpace::Model.required! +Dexpace::Operation +Dexpace::Operation#build_request +Dexpace::Operation#method +Dexpace::Operation#placeholders +Dexpace::Operation#projections +Dexpace::Operation#template +Dexpace::Operation.build +Dexpace::Operation.placeholders_in Dexpace::PercentEncoding Dexpace::PercentEncoding#decode_component Dexpace::PercentEncoding#encode_component @@ -123,6 +201,14 @@ Dexpace::Query::Builder#remove Dexpace::Query::Builder#set Dexpace::Query::Builder::SCALARS : Array Dexpace::Query::EMPTY : Dexpace::Query +Dexpace::Registry +Dexpace::Registry#install +Dexpace::Registry#register +Dexpace::Registry#registered_keys +Dexpace::Registry#resolve +Dexpace::Registry#seam +Dexpace::Registry#swap +Dexpace::Registry.callable? Dexpace::Request Dexpace::Request#== Dexpace::Request#body @@ -178,6 +264,18 @@ Dexpace::Response::Builder#protocol= Dexpace::Response::Builder#reason= Dexpace::Response::Builder#request= Dexpace::Response::Builder#status= +Dexpace::SeamError +Dexpace::Serde +Dexpace::Serde.conforms? +Dexpace::Serde.install +Dexpace::Serde.missing_methods +Dexpace::Serde.register +Dexpace::Serde.registered_keys +Dexpace::Serde.resolve +Dexpace::Serde.swap +Dexpace::Serde::DeserializationError +Dexpace::Serde::Error +Dexpace::Serde::SerializationError Dexpace::Status Dexpace::Status#canonical_name Dexpace::Status#client_error? @@ -206,6 +304,14 @@ Dexpace::Status::REQUEST_TIMEOUT : Dexpace::Status Dexpace::Status::SERVICE_UNAVAILABLE : Dexpace::Status Dexpace::Status::TOO_MANY_REQUESTS : Dexpace::Status Dexpace::Status::UNAUTHORIZED : Dexpace::Status +Dexpace::Transport +Dexpace::Transport.async_over +Dexpace::Transport.conforms? +Dexpace::Transport.install +Dexpace::Transport.register +Dexpace::Transport.registered_keys +Dexpace::Transport.resolve +Dexpace::Transport.swap Dexpace::URL Dexpace::URL#external_form Dexpace::URL#parse! diff --git a/test/fixtures/surface/dexpace-serde-json.txt b/test/fixtures/surface/dexpace-serde-json.txt index 59a611b..08ed3b4 100644 --- a/test/fixtures/surface/dexpace-serde-json.txt +++ b/test/fixtures/surface/dexpace-serde-json.txt @@ -1,3 +1,2 @@ -Dexpace::Serde Dexpace::Serde::JSON Dexpace::Serde::JSON::VERSION : String diff --git a/test/fixtures/surface/dexpace-transport-async_http.txt b/test/fixtures/surface/dexpace-transport-async_http.txt index 990231d..01b720e 100644 --- a/test/fixtures/surface/dexpace-transport-async_http.txt +++ b/test/fixtures/surface/dexpace-transport-async_http.txt @@ -1,3 +1,2 @@ -Dexpace::Transport Dexpace::Transport::AsyncHTTP Dexpace::Transport::AsyncHTTP::VERSION : String diff --git a/test/fixtures/surface/dexpace-transport-net_http.txt b/test/fixtures/surface/dexpace-transport-net_http.txt index 10e1ab3..c16aa5e 100644 --- a/test/fixtures/surface/dexpace-transport-net_http.txt +++ b/test/fixtures/surface/dexpace-transport-net_http.txt @@ -1,3 +1,2 @@ -Dexpace::Transport Dexpace::Transport::NetHTTP Dexpace::Transport::NetHTTP::VERSION : String diff --git a/test/gates/surface_snapshot_test.rb b/test/gates/surface_snapshot_test.rb index dc15937..cd37ef7 100644 --- a/test/gates/surface_snapshot_test.rb +++ b/test/gates/surface_snapshot_test.rb @@ -31,9 +31,11 @@ def self.build = nil LIMIT = 3 end - # Each adapter's manifest and the namespace it shares with its siblings, which is where its - # manifest begins: an adapter's lines are what it adds to the tree, and core's own `Dexpace` - # line is core's. + # Each adapter's manifest and the namespace it shares with its siblings. An adapter's lines are + # what it adds to the tree, and core's lines are core's: since phase 2 core defines all three + # shared namespaces (Dexpace::Transport, Dexpace::Serde, Dexpace::Async), so an adapter's + # manifest begins at its own constant inside the namespace, and the namespace line itself -- + # like the `Dexpace` line -- belongs to core's manifest. SHARED_NAMESPACES = { "dexpace-transport-net_http" => "Dexpace::Transport", "dexpace-transport-async_http" => "Dexpace::Transport", @@ -70,11 +72,12 @@ def self.build = nil refute_includes(err, "would not load") end - test "each adapter's committed manifest starts at the namespace it shares, not its own" do + test "each adapter's committed manifest starts inside the namespace it shares, none of core's" do SHARED_NAMESPACES.each do |name, namespace| lines = File.readlines(File.join(ROOT, "test/fixtures/surface/#{name}.txt"), chomp: true) - assert_equal(namespace, lines.first, name) + assert(lines.first.start_with?("#{namespace}::"), "#{name}: #{lines.first.inspect}") + refute_includes(lines, namespace, "#{name}: the shared namespace is core's since phase 2") refute_includes(lines, "Dexpace", "#{name}: core's own line belongs to core's manifest") end end