From d99d0ca95b09003f58a6032252723c465eb3edc8 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Tue, 15 Sep 2026 20:56:18 +0300 Subject: [PATCH 1/8] feat: Dexpace/QualifiedCoreConstant gains IO and the one-segment watch Dexpace::IO shadows ::IO for every file inside `module Dexpace` -- and core defines it, so the hazard is live from the first require. Inside that namespace `x.is_a?(IO)` and `IO === x` are silently false for a real ::IO on 3.2.11, 3.4.10 and 4.0.6 (design P3-7). SHADOWED gains exactly IO; File, StringIO and Tempfile are deliberately absent, under the standing rule that a constant joins the list in the same change that creates its shadow. WATCHED becomes the one-segment Dexpace, so the rule is "inside module Dexpace, anywhere", over every gem's lib/ (.rubocop.yml's Include: widens from two core namespaces to gems/*/lib/**/*.rb). A definition-site guard skips the constant a `module`/`class` node declares, because `module Dexpace; module IO` is itself a bare IO inside module Dexpace and the cop would otherwise reject the file that creates the hazard it exists for. The cop's own suite gains a second nested class with eight rejected and ten accepted sources, and phase 2's accepted row -- a bare Thread inside Dexpace::Transport -- moves to rejected, which the one-segment watch implies: a tightening, never a narrowing. --- .rubocop.yml | 14 ++- .../cops/dexpace/qualified_core_constant.rb | 87 ++++++++++++------- .rubocop/test/cops_test.rb | 71 ++++++++++++++- 3 files changed, 129 insertions(+), 43 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 14ae1b1..46eeb0a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -172,14 +172,12 @@ 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. +# Design §9 Addendum A1 (phase 2), widened by phase 3a's P3-7: a bare Thread/Queue/Mutex/JSON/IO +# inside module Dexpace resolves to the shadowing Dexpace:: constant -- the adapter gem's once that +# gem is required, and core's own Dexpace::IO from the first require. Every gem, because an adapter +# gem also writes inside module Dexpace. test/** is deliberately absent: a test file's classes are +# top level, so the cop's own lexical check would find nothing there. 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" + - "gems/*/lib/**/*.rb" diff --git a/.rubocop/cops/dexpace/qualified_core_constant.rb b/.rubocop/cops/dexpace/qualified_core_constant.rb index ae7a9e3..9d43b2e 100644 --- a/.rubocop/cops/dexpace/qualified_core_constant.rb +++ b/.rubocop/cops/dexpace/qualified_core_constant.rb @@ -4,50 +4,63 @@ 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. + # Inside `module Dexpace`, anywhere, a bare `Thread`, `Queue`, `Mutex`, `SizedQueue`, + # `ConditionVariable`, `JSON` or `IO` is a silent time bomb: it resolves to Ruby's class + # until the `Dexpace::` constant that shadows it is defined, and to that constant + # 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. + # Phase 2 verified the adapter-gem half on 3.2.11 and 4.0.6: a bare `Thread` inside + # `module Dexpace; module Async` is `Thread` before `dexpace-async-thread` is required and + # `Dexpace::Async::Thread` after, and core's own suite never requires that gem. # - # Scoped by `Include:` in .rubocop.yml to gems/dexpace-core/lib/dexpace/{async,serde}/**/*.rb. + # Phase 3a adds `IO`, and it is worse, because `Dexpace::IO` is defined by CORE. Verified on + # 3.2.11, 3.4.10 and 4.0.6: inside `module Dexpace`, `x.is_a?(IO)` and `IO === x` are + # silently `false` for a real `::IO`, and a `case/when IO` falls through -- while a bare + # `IO.pipe` is a loud NoMethodError. The silent case is exactly the shape design §3.1 reaches + # for, and `Response#body_string`, which phase 3b adds under lib/dexpace/http/, is the + # counter-example that forbids scoping this rule to lib/dexpace/io/**. # - # 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. + # The standing rule, for every later phase: a constant joins SHADOWED in the same change + # that creates the `Dexpace::` constant which shadows it -- never earlier, never later. + # `File`, `StringIO` and `Tempfile` are deliberately absent: no `Dexpace::` constant of those + # names exists, a bare `File` inside `module Dexpace` resolves to `::File` (verified), and a + # rule guarding nothing only flags correct code. # - # 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 `::`. + # Scoped by `Include:` in .rubocop.yml to gems/*/lib/**/*.rb -- every gem, because an + # adapter gem also writes inside `module Dexpace`. test/** is deliberately left out: a test + # file's classes are top level, not inside `module Dexpace`, so the lexical check below + # would find nothing there. + # + # The enclosing namespace is checked here rather than left to `Include:`. 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. # # @example - # # bad -- inside module Dexpace; module Async - # Queue.new + # # bad -- inside module Dexpace + # IO.pipe # # # good - # ::Queue.new + # ::IO.pipe # - # # good -- a different namespace; nothing reopens Dexpace::Transport::Thread - # Thread.new + # # good -- a different namespace; nothing named Elsewhere::IO exists + # IO.pipe class QualifiedCoreConstant < Base - MSG = "Write `::%s` here: a bare `%s` inside %s rebinds to the " \ - "adapter gem's constant once that gem is required." + MSG = "Write `::%s` here: a bare `%s` inside %s resolves to the " \ + "shadowing `Dexpace::` constant instead of Ruby's." - SHADOWED = %w[Thread Queue Mutex SizedQueue ConditionVariable JSON].freeze + SHADOWED = %w[Thread Queue Mutex SizedQueue ConditionVariable JSON IO].freeze - WATCHED = [%w[Dexpace Async], %w[Dexpace Serde]].freeze + # One segment, which subsumes phase 2's two: the rule is now "inside `module Dexpace`, + # anywhere". Phase 2 deliberately kept ONE SHADOWED list for both namespaces rather than + # one list each, and widening the watch rather than adding a per-constant scope keeps that + # decision intact. The cost is one `::` on every `::Thread::Mutex` core already writes in + # that form. + WATCHED = [%w[Dexpace]].freeze def on_const(node) - return if node.namespace # already qualified: `A::Thread` or `::Thread` + return if node.namespace # already qualified: `A::IO` or `::IO` + return if definition_name?(node) return unless SHADOWED.include?(node.short_name.to_s) namespace = watched_namespace(node) @@ -58,6 +71,18 @@ def on_const(node) private + # `module Dexpace; module IO` DECLARES the shadowing constant; it does not refer to Ruby's. + # Without this, the cop flags lib/dexpace/io.rb -- the very file that creates the hazard + # the rule exists to guard. Phase 2 never met this because no file declares + # `module Dexpace::Async::Thread`. + def definition_name?(node) + parent = node.parent + return false if parent.nil? + return false unless parent.module_type? || parent.class_type? + + parent.identifier.equal?(node) + end + # @return [String, nil] the watched namespace this node is lexically inside, or nil def watched_namespace(node) path = lexical_path(node) @@ -66,7 +91,7 @@ def watched_namespace(node) 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 + # `module Dexpace; module IO` and `module Dexpace::IO` 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) diff --git a/.rubocop/test/cops_test.rb b/.rubocop/test/cops_test.rb index f6b643a..b26b2c4 100644 --- a/.rubocop/test/cops_test.rb +++ b/.rubocop/test/cops_test.rb @@ -5,8 +5,9 @@ # 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. 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. +# names exactly one input. Phase 2's seventh cop has its own tables in the nested classes below, +# because Metrics/ClassLength caps one class at 100 lines and counts a table row as a line; +# phase 3a's extension of it (P3-7) has a second nested class for the same reason. class CopsTest < CopCase D = RuboCop::Cop::Dexpace HEADER = "# frozen_string_literal: true\n# SPDX-License-Identifier: MIT\n\n" @@ -132,7 +133,7 @@ class CopsTest < CopCase # 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 + # lexical path and is flagged too; one SHADOWED list serves every namespace, 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" @@ -153,6 +154,10 @@ class QualifiedCoreConstantTest < CopCase # 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"], + # Phase 2 accepted this row: nothing reopens Dexpace::Transport::Thread. Phase 3a's + # one-segment watch (P3-7) rejects it, because the rule is now "inside module Dexpace, + # anywhere" -- a tightening, never a narrowing, and the row moved rather than vanished. + ["module Dexpace\n module Transport\n Thread.new\n end\nend\n", "Write `::Thread` here"], ].freeze # The accepted half is what proves the cop does not reject every occurrence of the six names. @@ -160,7 +165,6 @@ class QualifiedCoreConstantTest < CopCase 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"), @@ -179,4 +183,63 @@ class QualifiedCoreConstantTest < CopCase end end end + + # Phase 3a (P3-7): SHADOWED gains `IO` and WATCHED becomes the one-segment `Dexpace`, so the rule + # is "inside `module Dexpace`, anywhere". Dexpace::IO is defined by CORE, so the hazard is live + # from the first require: inside `module Dexpace`, `x.is_a?(IO)` is silently false for a real + # ::IO. Its own nested class, for the same 100-line cap phase 2's rows sit under. + class QualifiedCoreConstantIOTest < CopCase + DEXPACE = "module Dexpace\n %s\nend\n" + IN_IO = "module Dexpace\n module IO\n %s\n end\nend\n" + IN_HTTP = "module Dexpace\n module Http\n %s\n end\nend\n" + + # [source, the fragment the message must carry] + REJECTED = [ + [format(DEXPACE, "IO.pipe"), "Write `::IO` here"], + [format(IN_IO, "x.is_a?(IO)"), "Write `::IO` here"], + [format(IN_HTTP, "x.is_a?(IO)"), "Write `::IO` here"], + ["module Dexpace::Async\n IO.pipe\nend\n", "Write `::IO` here"], + # Phase 2's constants, still rejected under the widened one-segment watch. + ["module Dexpace\n module Async\n Thread.new\n end\nend\n", "Write `::Thread` here"], + ["module Dexpace\n module Serde\n JSON.generate(x)\n end\nend\n", "Write `::JSON` here"], + # The widening itself: a bare Mutex or Queue anywhere inside module Dexpace, not only in the + # two namespaces phase 2 watched. + [format(DEXPACE, "Mutex.new"), "Write `::Mutex` here"], + [format(IN_HTTP, "Queue.new"), "Write `::Queue` here"], + ].freeze + + # The accepted half is what proves the cop does not reject every occurrence of the name. + ACCEPTED = [ + format(DEXPACE, "::IO.pipe"), + format(IN_IO, "x.is_a?(::IO)"), + format(IN_HTTP, "::IO.pipe"), + "module Dexpace::Async\n ::IO.pipe\nend\n", + # No enclosing module at all. + "IO.pipe\n", + # An unrelated namespace: nothing named Elsewhere::IO exists. + "module Elsewhere\n IO.pipe\nend\n", + # File, StringIO and Tempfile are deliberately NOT in SHADOWED: no Dexpace:: constant of + # those names exists, so a rule covering them would only flag correct code. + format(DEXPACE, "File.read(path)\n StringIO.new\n Tempfile.create"), + # Written out in full. + format(DEXPACE, "Dexpace::IO::Buffer.new"), + # THE definition site. `module Dexpace; module IO` DECLARES the shadowing constant rather + # than referring to Ruby's, so lib/dexpace/io.rb -- the file that creates the hazard -- is + # not itself an offense. Without these two rows the cop rejects the phase that ships it. + "module Dexpace\n module IO\n X = 1\n end\nend\n", + "module Dexpace\n module IO\n class Buffer\n end\n end\nend\n", + ].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 From 9f263688ee9751b8b34c811c4f27b7941cf35c46 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Tue, 15 Sep 2026 20:56:40 +0300 Subject: [PATCH 2/8] feat: the byte-streaming layer -- Dexpace::IO and its two failure types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dexpace::StreamError < ::IOError, with the two BODY-13 message helpers short_transfer and zero_read, and Dexpace::EndOfStreamError < ::EOFError (IO-4, IO-9, IO-11, IO-12, IO-15, IO-16, IO-17, IO-28; P3-3). The superclass is load-bearing: IO.copy_stream -- what Net::HTTP#body_stream= calls -- terminates only on an EOFError subclass from a duck-typed #readpartial. Dexpace::IOError and Dexpace::EOFError are never defined. Dexpace::Closeable#closed? reads the latch under the close mutex (IO-38, P3-6): the first requirement that reads the flag from a second thread, and design §3.1 declines to rely on the GVL. Dexpace::IO with MAX_MATERIALIZED_BYTES (IO-9, §10.18) and the RBS interfaces _Source, _Sink and _Chunked -- the last being design §10.2's canonical body representation as a type. TypedReads supplies the read vocabulary over one private #fill hook: #read_into, IO-1's tail-append primitive, deliberately not #read (P3-1), because #read, #readpartial, #getbyte and #each keep Ruby's semantics and are IO-16's bridge (P3-2, P3-13); the typed reads under the one materialisation guard (P3-4); the hand-written line machine (IO-14); #peek and #slice, views that pin the parent's cursor and never make it retain (P3-5), with invalidation cascading through a view's own views on close. TypedWrites supplies the write vocabulary over #deliver: #write_from, the ::IO-compatible #write, #write_all with IO-17's unconditional zero-read rule, the charset writes, #emit and #flush (P3-9). BufferedSource (three factories, one ownership rule read from IO-6 and design §10.12, never SEAM-3; P3-11, P3-12), Buffer (IO-7..IO-10, exempt from use-after-close on its own surface and not for its views, P3-10), BufferedSink and TeeSink (IO-25..IO-29, without #clear_tap: 3b builds a fresh tee per write). lib/dexpace.rb requires the nine files in dependency order; every sig/ mirror declares every method for the strict core Steep target. --- gems/dexpace-core/lib/dexpace.rb | 12 + gems/dexpace-core/lib/dexpace/closeable.rb | 12 +- .../lib/dexpace/error/end_of_stream_error.rb | 20 + .../lib/dexpace/error/stream_error.rb | 36 + gems/dexpace-core/lib/dexpace/io.rb | 28 + gems/dexpace-core/lib/dexpace/io/buffer.rb | 125 ++++ .../lib/dexpace/io/buffered_sink.rb | 101 +++ .../lib/dexpace/io/buffered_source.rb | 265 ++++++++ gems/dexpace-core/lib/dexpace/io/tee_sink.rb | 135 ++++ .../lib/dexpace/io/typed_reads.rb | 633 ++++++++++++++++++ .../lib/dexpace/io/typed_writes.rb | 241 +++++++ .../sig/dexpace/error/end_of_stream_error.rbs | 5 + .../sig/dexpace/error/stream_error.rbs | 8 + gems/dexpace-core/sig/dexpace/io.rbs | 23 + gems/dexpace-core/sig/dexpace/io/buffer.rbs | 24 + .../sig/dexpace/io/buffered_sink.rbs | 27 + .../sig/dexpace/io/buffered_source.rbs | 51 ++ gems/dexpace-core/sig/dexpace/io/tee_sink.rbs | 32 + .../sig/dexpace/io/typed_reads.rbs | 77 +++ .../sig/dexpace/io/typed_writes.rbs | 40 ++ 20 files changed, 1894 insertions(+), 1 deletion(-) create mode 100644 gems/dexpace-core/lib/dexpace/error/end_of_stream_error.rb create mode 100644 gems/dexpace-core/lib/dexpace/error/stream_error.rb create mode 100644 gems/dexpace-core/lib/dexpace/io.rb create mode 100644 gems/dexpace-core/lib/dexpace/io/buffer.rb create mode 100644 gems/dexpace-core/lib/dexpace/io/buffered_sink.rb create mode 100644 gems/dexpace-core/lib/dexpace/io/buffered_source.rb create mode 100644 gems/dexpace-core/lib/dexpace/io/tee_sink.rb create mode 100644 gems/dexpace-core/lib/dexpace/io/typed_reads.rb create mode 100644 gems/dexpace-core/lib/dexpace/io/typed_writes.rb create mode 100644 gems/dexpace-core/sig/dexpace/error/end_of_stream_error.rbs create mode 100644 gems/dexpace-core/sig/dexpace/error/stream_error.rbs create mode 100644 gems/dexpace-core/sig/dexpace/io.rbs create mode 100644 gems/dexpace-core/sig/dexpace/io/buffer.rbs create mode 100644 gems/dexpace-core/sig/dexpace/io/buffered_sink.rbs create mode 100644 gems/dexpace-core/sig/dexpace/io/buffered_source.rbs create mode 100644 gems/dexpace-core/sig/dexpace/io/tee_sink.rbs create mode 100644 gems/dexpace-core/sig/dexpace/io/typed_reads.rbs create mode 100644 gems/dexpace-core/sig/dexpace/io/typed_writes.rbs diff --git a/gems/dexpace-core/lib/dexpace.rb b/gems/dexpace-core/lib/dexpace.rb index 04fef0d..734f391 100644 --- a/gems/dexpace-core/lib/dexpace.rb +++ b/gems/dexpace-core/lib/dexpace.rb @@ -47,6 +47,18 @@ require_relative "dexpace/serde" require_relative "dexpace/operation" +# Phase 3a: the byte-streaming layer, in dependency order -- the two failure types, the namespace +# with its ceiling, the read vocabulary, the reader, the write vocabulary, then the three sinks. +require_relative "dexpace/error/stream_error" +require_relative "dexpace/error/end_of_stream_error" +require_relative "dexpace/io" +require_relative "dexpace/io/typed_reads" +require_relative "dexpace/io/buffered_source" +require_relative "dexpace/io/typed_writes" +require_relative "dexpace/io/buffer" +require_relative "dexpace/io/buffered_sink" +require_relative "dexpace/io/tee_sink" + # 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/closeable.rb b/gems/dexpace-core/lib/dexpace/closeable.rb index e6862e4..a5d991c 100644 --- a/gems/dexpace-core/lib/dexpace/closeable.rb +++ b/gems/dexpace-core/lib/dexpace/closeable.rb @@ -40,9 +40,19 @@ def owned? # 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. + # + # IO-38 (phase 3a, P3-6): the latch is read under the same Thread::Mutex that writes it, so a + # close on one thread reliably invalidates a slice being read on another. Design §3.1 fixes + # the mechanism -- "written and read through a Thread::Mutex rather than relying on the GVL, + # so the guarantee survives JRuby and TruffleRuby" -- and phase 2's unsynchronised read relied + # on exactly the GVL that sentence declines to rely on. Measured at ~40 ns per call on 3.2.11, + # 3.4.10 and 4.0.6, paid once per public entry point and never per byte. No GVL-free + # interpreter is in the matrix (3a's design, "Work Phase 3a Postpones"), so on every CI row + # this read passes with or without the lock: the assertion that distinguishes them is the + # fiber-held-mutex test in closeable_test.rb. def closed? ensure_closeable_initialized - @dexpace_closed + @dexpace_close_mutex.synchronize { @dexpace_closed } end # Idempotent and ownership-aware. The mutex is held across the flag flip and nothing else: diff --git a/gems/dexpace-core/lib/dexpace/error/end_of_stream_error.rb b/gems/dexpace-core/lib/dexpace/error/end_of_stream_error.rb new file mode 100644 index 0000000..0b4440f --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/error/end_of_stream_error.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../error" + +module Dexpace + # End of stream reached by a read form that cannot report it any other way: #read_exactly, + # #readbyte, #readpartial, #skip (IO-11, IO-12, IO-15, IO-16). + # + # The superclass is load-bearing, not tidy. Verified on 3.2.11, 3.4.10 and 4.0.6 that + # IO.copy_stream -- which is literally what Net::HTTP#send_request_with_body_stream calls, so it + # is the code path every streamed upload takes -- terminates cleanly on an ::EOFError SUBCLASS + # raised by a duck-typed #readpartial. Outside that family copy_stream propagates and every + # upload phase 8 performs fails. Ruby's own readers raise ::EOFError for exactly this condition. + # + # Dexpace::EOFError is never defined, for the Dexpace::ArgumentError reason phase 1 recorded. + class EndOfStreamError < ::EOFError + include Dexpace::Error + end +end diff --git a/gems/dexpace-core/lib/dexpace/error/stream_error.rb b/gems/dexpace-core/lib/dexpace/error/stream_error.rb new file mode 100644 index 0000000..de05b7c --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/error/stream_error.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../error" + +module Dexpace + # A stream-contract violation: a sink asked for more bytes than the source buffer holds (IO-4), + # a source that returned 0 for a positive requested count (IO-17), an underlying sink that + # accepted fewer bytes than it was handed, a materialisation over Dexpace::IO's ceiling (IO-9), + # or a reach for a TeeSink's backing buffer (IO-28). + # + # It subclasses ::IOError because IO-4, IO-17 and IO-42 each literally say "an I/O error" and + # ::IOError is Ruby's root for that family. Deliberately NOT because of XCUT-4: XCUT-4 requires + # a *transport* error to report itself as always-retryable, and a stream-contract violation must + # not make that claim. This is a sibling of phase 8's Dexpace::TransportError inside Ruby's I/O + # family and never a subclass of it. + # + # Dexpace::IOError is never defined, for the Dexpace::ArgumentError reason phase 1 recorded: it + # would shadow ::IOError for every file inside `module Dexpace`. + class StreamError < ::IOError + include Dexpace::Error + + # BODY-13 requires the short-transfer message form of BODY-10/HTTP-39 to come from ONE helper + # "so the message form cannot diverge". 3a owns the I/O half; 3b calls this. It builds and + # returns rather than raising, so the raise site -- and its backtrace -- stays at the violation. + def self.short_transfer(transferred:, expected:) + new("transferred #{transferred} bytes but #{expected} were expected") + end + + # IO-17's source-contract violation, and BODY-25's message form. One helper, same reason. + def self.zero_read(requested:) + new("a source returned 0 bytes for a requested count of #{requested}; " \ + "a read of 0 for a positive count is a source-contract violation (IO-17)") + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/io.rb b/gems/dexpace-core/lib/dexpace/io.rb new file mode 100644 index 0000000..17d6abb --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/io.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +module Dexpace + # The byte-streaming layer: one FIFO buffer, one buffered source/sink pair with typed reads and + # non-consuming views, and one tee sink. Design §10.1 retired the provider seam that used to make + # this pluggable, so the behavioural contract is the whole deliverable -- there is no registry, + # no factory and no installation call here. + # + # HAZARD, stated where a reader meets it. This constant shadows ::IO for every file inside + # `module Dexpace` AND inside a consumer's own `class C; include Dexpace`, because `include` + # inserts Dexpace ahead of Object in C.ancestors. `x.is_a?(IO)` is then silently false for a + # real ::IO, with no error and no warning, and a `case/when IO` falls through. A top-level + # `include Dexpace` is unaffected, because Object's own constant table is searched first. Core + # never writes `is_a?(IO)`: every stream this layer accepts is checked with respond_to?, and + # Dexpace/QualifiedCoreConstant makes a bare `IO` inside `module Dexpace` a RuboCop offense. + # No gate this repository owns reaches a consumer's file, which is why the same warning is owed + # in docs/sdk-documentation/ before anything is published (docs/first-release.md). + module IO + # IO-9's ceiling. Design §10.18 substitutes it for a host maximum single-array allocation Ruby + # does not have, and §3.1 fixes the default at 64 MiB -- "chosen, not derived". Every + # operation that produces one contiguous String reads this constant directly; nothing takes + # it as a keyword. Phase 5 owns the configuration source (R5; phase 2's deadline: precedent, + # P2-5): adding an optional keyword later widens a signature rather than narrowing one, so + # NFR-4 is not prejudiced. + MAX_MATERIALIZED_BYTES = 64 * 1024 * 1024 + end +end diff --git a/gems/dexpace-core/lib/dexpace/io/buffer.rb b/gems/dexpace-core/lib/dexpace/io/buffer.rb new file mode 100644 index 0000000..992f829 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/io/buffer.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "typed_reads" +require_relative "typed_writes" +require_relative "../closeable" +require_relative "../error/stream_error" +require_relative "../error/invalid_argument_error" + +module Dexpace + module IO + # The FIFO: IO-7..IO-10, IO-41, IO-42. An Array of BINARY String chunks behind a head byte + # offset, so draining is O(1) amortised (§3.1). + # + # It includes BOTH vocabularies, which is what makes it "simultaneously a source and a sink" + # (IO-7). It is deliberately NOT a BufferedSource subclass: it would inherit .wrapping, + # .of_bytes and .over, and Buffer.wrapping(io) is a nonsense factory that would appear in the + # surface manifest. .new is public because a Buffer has exactly one construction meaning and + # needs no factory to name it (P3-11); phase 1's private .new plus a validating .build is a + # rule about Data value types and does not reach a mutable, stateful object. + # + # IO-42's asymmetry, in both directions: the in-memory read/write surface stays live after + # close, because an in-memory close frees nothing and snapshot-after-close body logging + # (BODY-28) depends on it; and the close still invalidates every view derived from the buffer. + # Neither direction may be simplified into the other. + class Buffer + include Dexpace::IO::TypedReads + include Dexpace::IO::TypedWrites + include Dexpace::Closeable + + def initialize + initialize_closeable(owned: true) + initialize_typed_reads + initialize_typed_writes + end + + # IO-7's size. Named for bytes because everything here is bytes and String#bytesize is the + # precedent. + def bytesize + @dexpace_buffered + end + + # IO-8: a fresh, independent, UNFROZEN BINARY copy that neither consumes nor mutates the + # buffer. Unfrozen deliberately (P3-10) -- IO-8's "and vice versa" presumes the caller may + # mutate it, and the styleguide's freeze-every-returned-collection rule names collections, + # hashes and structs, which a String is none of. + # + # Guarded by MAX_MATERIALIZED_BYTES (IO-9): the size is known here, so an over-ceiling + # snapshot refuses having allocated nothing. + def snapshot + guard_materialization!(@dexpace_buffered) + store_peek(0, @dexpace_buffered) + end + + # IO-10. + def clear + @dexpace_chunks.clear + @dexpace_head = 0 + @dexpace_consumed += @dexpace_buffered + @dexpace_buffered = 0 + nil + end + + # IO-10: copies a window into another Buffer WITHOUT consuming or mutating this one, + # defaulting to "from offset through end", and rejecting an out-of-range window eagerly. + # Not a ceiling case (P3-4): it materialises nothing contiguous of its own -- the window + # goes into `other`'s chunk store as one chunk. + def copy_to(other, offset: 0, count: nil) + unless other.is_a?(Dexpace::IO::Buffer) + raise Dexpace::InvalidArgumentError, + "copy_to takes a Dexpace::IO::Buffer, got #{other.class}" + end + + span = validate_window!(offset, count) + other.write(store_peek(offset, span)) unless span.zero? + nil + end + + private + + # IO-10's out-of-range rule, IO-3's eager rejection: a negative offset or count, or a window + # reaching past the end, is an argument error before anything is copied. Returns the + # window's length, which defaults to "from offset through end". + def validate_window!(offset, count) + validate_count!(offset, name: "offset") + validate_count!(count, name: "count") unless count.nil? + span = count.nil? ? @dexpace_buffered - offset : count + return span unless span.negative? || offset + span > @dexpace_buffered + + raise Dexpace::InvalidArgumentError, + "window offset #{offset}, count #{count.inspect} is outside a buffer of " \ + "#{@dexpace_buffered} bytes" + end + + # A Buffer has no upstream, so there is nothing to fill from. IO-7's source surface reads + # exactly what its sink surface wrote. + def fill(_min_bytes) + 0 + end + + # IO-7: bytes written through the sink surface land in the same store the source surface + # reads, in the exact order written. + def deliver(string) + store_append(string) + nil + end + + # IO-42's in-memory exemption, in both directions. An in-memory close frees nothing, so + # snapshot-after-close body logging still works. + def reads_survive_close? + true + end + + def writes_survive_close? + true + end + + # IO-42's other half, which the exemption does NOT cover: close still invalidates every + # view derived from this buffer (IO-22, IO-38). + def release + dexpace_release_views + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/io/buffered_sink.rb b/gems/dexpace-core/lib/dexpace/io/buffered_sink.rb new file mode 100644 index 0000000..bd68544 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/io/buffered_sink.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "typed_writes" +require_relative "../closeable" +require_relative "../error/stream_error" +require_relative "../error/invalid_argument_error" + +module Dexpace + module IO + # The writer: IO-4, IO-5, IO-6, IO-16..IO-18, IO-41, IO-42. + # + # #deliver stages the bytes and pushes them one level immediately, so the staging buffer is + # empty between calls and a failed underlying write cannot leave stale bytes to prepend. That + # keeps IO-18's distinction real without inventing a flush threshold this port would then + # have to configure: #emit pushes staged bytes toward the underlying stream and never calls + # the underlying #flush, and #flush does both. + class BufferedSink + include Dexpace::IO::TypedWrites + include Dexpace::Closeable + + private_class_method :new + + # IO-6: the returned wrapper TAKES OWNERSHIP -- closing it closes `io`. No borrowing variant + # (P3-12), and .new is private so ownership cannot be set through an unnamed argument + # (P3-11). `io` is anything responding to #write; checked with respond_to?, never is_a?. + # + # With a block, closes on any exit path and returns the block's value; without one the + # caller owns the close. + def self.wrapping(io) + unless io.respond_to?(:write) + raise Dexpace::InvalidArgumentError, + "a wrapped stream must respond to #write, got #{io.class}" + end + + sink = new(io) + return sink unless block_given? + + begin + yield sink + ensure + sink.close + end + end + + def initialize(io) + @dexpace_underlying = io + @dexpace_staged = (+"").b + initialize_closeable(owned: true) + initialize_typed_writes + end + + private + + def deliver(string) + @dexpace_staged << string + push_one_level + end + + def push_one_level + return nil if @dexpace_staged.empty? + + payload = @dexpace_staged + check_full_write(write_through(payload), payload.bytesize) + end + + # The staging buffer is cleared in an ensure, so a failed underlying write leaves nothing + # behind for the next one to prepend -- and an ensure rather than a rescue, so nothing the + # underlying stream raises is swallowed or duplicated (IO-40). + def write_through(payload) + @dexpace_underlying.write(payload) + ensure + @dexpace_staged = (+"").b + end + + # IO-17's rule, applied symmetrically on the write side: an underlying #write that accepted + # fewer bytes than it was handed is a sink-contract violation. A destination that reports + # no count at all -- a caller's own #write-shaped object returning something else -- is + # taken at its word. + def check_full_write(reported, expected) + return nil unless reported.is_a?(::Integer) + return nil if reported >= expected + + raise Dexpace::StreamError.short_transfer(transferred: reported, expected: expected) + end + + def push_all + push_one_level + @dexpace_underlying.flush if @dexpace_underlying.respond_to?(:flush) + nil + end + + # IO-6/IO-41: flush what is staged, then close the underlying stream, exactly once. + def release + push_one_level + @dexpace_underlying.close if @dexpace_underlying.respond_to?(:close) + nil + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/io/buffered_source.rb b/gems/dexpace-core/lib/dexpace/io/buffered_source.rb new file mode 100644 index 0000000..b3bc540 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/io/buffered_source.rb @@ -0,0 +1,265 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "typed_reads" +require_relative "../closeable" +require_relative "../error/invalid_argument_error" +require_relative "../error/closed_error" + +module Dexpace + module IO + # The reader: IO-6, IO-11..IO-24, IO-41, IO-42. Also, per design §10.2, a canonical body + # representation, because it responds to #each yielding BINARY chunks. + # + # Ownership is two frozen construction-time facts, never a close-time judgement. Closeable's + # @owned is always true -- a source always owns its own buffer -- and a separate frozen + # @dexpace_owns_upstream decides whether #release also closes the upstream. The factory names + # carry it: .wrapping owns (IO-6), .of_bytes and .over own nothing. IO-6 is read out of + # appendix C, and cited with design §10.12; SEAM-3, which the older statements of the rule + # cite, is retired (docs/knowledge/notes/message-bodies.md). + class BufferedSource # rubocop:disable Metrics/ClassLength -- three factories, three fill paths, one class; see above + include Dexpace::IO::TypedReads + include Dexpace::Closeable + + # The three facts that make a source a VIEW, folded into one value: the object it reads + # through, the absolute position in that object's byte stream where its window starts, and + # how wide that window is. One argument rather than three keeps #initialize inside + # Metrics/ParameterLists' budget of four and makes "is this a view?" one nil check. A Data + # instance is frozen on construction, which is exactly what a construction-time fact wants. + # private_constant, so it is not NFR-4 surface and the runtime snapshot's constant walk, + # which skips private constants, never sees it. + View = ::Data.define(:parent, :pin, :window) + + private_constant :View + + private_class_method :new + + # IO-6: the returned wrapper TAKES OWNERSHIP -- closing it closes `io`. There is deliberately + # no borrowing variant, because a wrapper that did not close what it wrapped is the exact + # object IO-6 prohibits (P3-12). + # + # `io` is anything responding to #readpartial or #read. Checked with respond_to?, never + # is_a?(IO): a nominal test would be silently false inside `module Dexpace`, and the duck + # test is what makes a StringIO, an IO.pipe end, a Tempfile and a caller's own + # #readpartial-shaped object all work. + # + # With a block, closes on any exit path and returns the block's value + # (`resource-management/bf5560dc`); without one the caller owns the close. + def self.wrapping(io) + unless io.respond_to?(:readpartial) || io.respond_to?(:read) + raise Dexpace::InvalidArgumentError, + "a wrapped stream must respond to #readpartial or #read, got #{io.class}" + end + + source = new(upstream: io, owns_upstream: true) + return source unless block_given? + + begin + yield source + ensure + source.close + end + end + + # An INDEPENDENT copy of the input, so a later mutation of the caller's String does not + # change the source and vice versa (IO-30's surviving behavioural clause, kept even though + # the ID is a permanent simplification). Owns no external resource -- IO-6's last sentence. + # + # The copy is taken here, frozen, and handed on as a ONE-CHUNK #each-shaped body, because a + # byte array already IS a canonical body representation (§10.2). That leaves one buffered + # construction path instead of two to keep in step, and #store_append keeps an + # already-frozen BINARY chunk without copying it a second time. + def self.of_bytes(string) + unless string.is_a?(::String) + raise Dexpace::InvalidArgumentError, "of_bytes takes a String, got #{string.class}" + end + + new(chunked: [string.b.freeze].freeze) + end + + # Design §3.1's single inverse adapter over §10.2's duck type: any object responding to + # #each and yielding String chunks. It pulls on demand so no read-ahead accumulates, and it + # OWNS NOTHING -- closing the returned source never calls #close on what it wrapped, because + # its callers are always downstream of something that already owns the response (R3, + # `message-bodies/f060d944`). + # + # §7.1's residue, documented rather than papered over: a source closed before exhaustion + # abandons the enumerator this drives, and an abandoned Enumerator never runs its ensure + # (verified on 3.2.11, 3.4.10 and 4.0.6; #rewind does not run it either). A #each-shaped + # object that holds a resource must expose #close and be closed by its owner, because .over + # will not. + def self.over(chunked) + unless chunked.respond_to?(:each) + raise Dexpace::InvalidArgumentError, + "over takes an object responding to #each, got #{chunked.class}" + end + + new(chunked: chunked) + end + + # The view constructor #peek and #slice reach. Not public API: a view is a BufferedSource in + # view mode, which makes IO-23's slice-of-a-slice free and adds no NFR-4-locked constant. + def self.__dexpace_view(parent:, pin:, window:) + new(view: View.new(parent: parent, pin: pin, window: window)) + end + + def initialize(upstream: nil, owns_upstream: false, chunked: nil, view: nil) + @dexpace_upstream = upstream + @dexpace_owns_upstream = owns_upstream + @dexpace_enumerator = chunked&.to_enum(:each) + @dexpace_view = view + @dexpace_pulled = 0 + @dexpace_upstream_exhausted = upstream.nil? && chunked.nil? && view.nil? + initialize_closeable(owned: true) + initialize_typed_reads + freeze_construction_facts + end + + # IO-19/IO-20: true when this source is a non-consuming view over another. + def view? + !@dexpace_view.nil? + end + + # IO-6: whether #close also closes the stream this source wraps. + def owns_upstream? + @dexpace_owns_upstream + end + + protected + + def dexpace_remaining_window + window = @dexpace_view&.window + return nil if window.nil? + + [window - dexpace_consumed, 0].max + end + + private + + # Both construction-time facts are frozen the moment they exist -- a boolean is, and a Data + # instance is frozen on construction -- so this asserts rather than achieves. It is here + # because the whole ownership and view story rests on neither ever changing after + # #initialize returns, and an assertion is what a later reader can check. + def freeze_construction_facts + @dexpace_owns_upstream.freeze + @dexpace_view.freeze + nil + end + + # The IO-1/IO-16 EOF sentinels are normalised HERE and nowhere else: readpartial's EOFError + # and read's nil become one @dexpace_upstream_exhausted flag, which short-circuits every + # later call. So the rescue runs at most once per stream rather than once per read, and the + # hot path -- "the bytes are already buffered" -- never enters this method at all. + def fill(min_bytes) + return 0 if @dexpace_upstream_exhausted + + view = @dexpace_view + return fill_from_parent(view, min_bytes) unless view.nil? + + enumerator = @dexpace_enumerator + return fill_from_enumerator(enumerator) unless enumerator.nil? + + fill_from_upstream(min_bytes) + end + + # On this path a zero-length return IS exhaustion, and that is a decision: ::IO#read(n), + # StringIO#read(n), ::IO#readpartial(n) and StringIO#readpartial(n) never return "" for a + # positive n on 3.2.11, 3.4.10 or 4.0.6 -- they return nil or raise -- so "" is unreachable + # for every reader Net::HTTP or a test will hand this, and a caller's own object that + # returns "" forever would spin if it were retried instead (IO-17's zero-read clause, seen + # from the other side of the contract). + def fill_from_upstream(min_bytes) + want = [min_bytes, 1].max + chunk = pull_from_upstream(want) + if chunk.nil? || chunk.empty? + @dexpace_upstream_exhausted = true + return 0 + end + store_append(chunk) + end + + def pull_from_upstream(want) + upstream = @dexpace_upstream + return upstream.read(want) unless upstream.respond_to?(:readpartial) + + upstream.readpartial(want) + rescue ::EOFError + nil + end + + # StopIteration is the ONLY end-of-stream signal on this path, so a fill that adds no bytes + # is retried rather than latched. #each yielding "" is ordinary -- Rack permits it and + # ["ab", "", "cd"].each is the shortest example -- and a zero-length chunk means "no bytes + # this time", never "no bytes ever". Collapsing the two truncates a body at its first empty + # chunk, silently and with a well-formed short read, and violates IO-1's "at least 1 when + # byteCount>0 and the source is not exhausted". + def fill_from_enumerator(enumerator) + added = 0 + added = store_append(enumerator.next) while added.zero? + added + rescue ::StopIteration + @dexpace_upstream_exhausted = true + 0 + end + + # P3-5, which the specification does not state and 3a therefore does. A view pins the + # parent's cursor at construction and reads a window relative to that pin, driving the + # parent's #fill without advancing the parent's cursor. If the parent consumes past the + # window, the view's later reads fail loudly rather than returning bytes from somewhere + # else -- IO-22's "never returning stale or arbitrary bytes" is the anchor. + def fill_from_parent(view, min_bytes) + want = window_budget(view, min_bytes) + return 0 if want.zero? + + parent = view.parent + behind = offset_into_parent(view, parent) + available = parent.dexpace_ensure_buffered(behind + want) - behind + return 0 if available <= 0 + + take = [available, want].min + @dexpace_pulled += take + # The window copy is fresh and this view owns it, so it is frozen here rather than inside + # #store_append -- which keeps "at most one copy on the fill path" true on this path too. + store_append(parent.dexpace_window_copy(behind, take).freeze) + end + + # How many bytes this fill may pull: at least one, at most `min_bytes`, and never past the + # window when there is one (a nil window is a peek's, unbounded). Zero once the window is + # spent, which the caller reads as end of window. + def window_budget(view, min_bytes) + want = [min_bytes, 1].max + window = view.window + return want if window.nil? + + (window - @dexpace_pulled).clamp(0, want) + end + + # How far ahead of the parent's own cursor this view's next byte sits -- and P3-5's loud + # failure when that number goes negative. The parent holds nothing back for a view, so a + # byte its cursor has already passed cannot be served from anywhere, and IO-22 forbids + # serving it from somewhere else. + def offset_into_parent(view, parent) + behind = view.pin + @dexpace_pulled - parent.dexpace_consumed + return behind unless behind.negative? + + raise Dexpace::ClosedError, + "this view's window starts #{-behind} bytes behind the source's cursor: the " \ + "source was read past the pin this view was taken at" + end + + # IO-22/IO-41/IO-42. The latch has already flipped; this runs exactly once. Drop the buffer, + # release this view's registration with its parent, invalidate every view derived from THIS + # source, then close the upstream only if this source owns it. + def release + @dexpace_chunks.clear + @dexpace_head = 0 + @dexpace_buffered = 0 + @dexpace_view&.parent&.dexpace_forget_view(self) + dexpace_release_views + upstream = @dexpace_upstream + upstream.close if @dexpace_owns_upstream && upstream.respond_to?(:close) + nil + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/io/tee_sink.rb b/gems/dexpace-core/lib/dexpace/io/tee_sink.rb new file mode 100644 index 0000000..9241930 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/io/tee_sink.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "typed_writes" +require_relative "buffer" +require_relative "../closeable" +require_relative "../error/stream_error" +require_relative "../error/invalid_argument_error" + +module Dexpace + module IO + # The mirror: IO-25..IO-29, IO-40. + # + # Its own class rather than a BufferedSink subclass. IO-29 makes its flush, close and emit + # forward to the primary only, so inheriting a sink's lifecycle and then overriding three + # quarters of it would be inheritance used as a shortcut. Design §3.1 already says the tee is + # hand-built rather than assembled from IO.pipe or IO.copy_stream. + # + # There is no #clear_tap (the plan's Task 14 decision, taken in the open): phase 3b satisfies + # BODY-18 by building a fresh tee per write of the wrapped body, which this class binding its + # primary at construction forces -- a retry writes to a different sink -- so the method had no + # caller anywhere in core while being NFR-4-locked surface. A caller reusing a tee across + # attempts builds a new one; the tap is reachable only through #tap_snapshot's fresh copy. + class TeeSink + include Dexpace::IO::TypedWrites + include Dexpace::Closeable + + # IO-26: the default limit MUST be effectively unbounded (nil here), and a limit of 0 + # mirrors nothing while still forwarding everything. `primary` is anything responding to + # #write, checked with respond_to?, never is_a?. + def initialize(primary:, tap_limit: nil) + unless primary.respond_to?(:write) + raise Dexpace::InvalidArgumentError, + "primary must respond to #write, got #{primary.class}" + end + + @dexpace_primary = primary + @dexpace_tap_limit = validated_tap_limit(tap_limit) + @dexpace_tap = Dexpace::IO::Buffer.new + @dexpace_staged = (+"").b + initialize_closeable(owned: true) + initialize_typed_writes + end + + # IO-8 over the tap: a fresh, independent copy. The tap is readable only through this. + def tap_snapshot + @dexpace_tap.snapshot + end + + # How many bytes the tap currently holds -- at most the limit, and never the payload's + # length once the limit is reached. + def tap_bytesize + @dexpace_tap.bytesize + end + + # IO-28. Defined rather than absent, so the failure is this message and not a NoMethodError. + # Design §10.10 records the honest position: the prohibition cannot be language-enforced -- + # instance_variable_get reaches anything -- and 3a does not build a fake proof that it can. + def buffer + raise Dexpace::StreamError, + "a TeeSink exposes no backing buffer: a direct buffer write would reach only the " \ + "tap or only the primary and silently corrupt the wire body (IO-28). Use the typed " \ + "write methods -- #write, #write_from, #write_all, #write_utf8, #write_string." + end + + private + + # IO-26's limit: a non-negative Integer, or nil for unbounded. Float::INFINITY is accepted + # as the spelling of "unbounded" a caller may reach for; any other Float is a mistake. + def validated_tap_limit(limit) + return nil if limit.nil? || limit == ::Float::INFINITY + return limit if limit.is_a?(::Integer) && !limit.negative? + + raise Dexpace::InvalidArgumentError, + "tap_limit must be a non-negative Integer or nil, got #{limit.inspect}" + end + + # IO-27: the attempted bytes are mirrored into the tap BEFORE they are forwarded, so a + # primary write that fails mid-stream still leaves them captured. + def deliver(string) + @dexpace_staged << string + mirror(@dexpace_staged) + payload = @dexpace_staged + check_full_write(forward(payload), payload.bytesize) + end + + # The staging buffer is cleared in an ensure -- not a rescue, so nothing is swallowed, which + # is also how IO-40's "MUST NOT swallow OR duplicate the wrapped stream's cancellation/ + # interrupt handling" is honoured structurally: the primary's own failure propagates once, + # as the identical object. + def forward(payload) + @dexpace_primary.write(payload) + ensure + @dexpace_staged = (+"").b + end + + # IO-17's rule on the write side, as BufferedSink applies it: a primary that accepted fewer + # bytes than it was handed is a sink-contract violation; one reporting no count is taken at + # its word. + def check_full_write(reported, expected) + return nil unless reported.is_a?(::Integer) + return nil if reported >= expected + + raise Dexpace::StreamError.short_transfer(transferred: reported, expected: expected) + end + + # IO-25/IO-26: once the limit is reached the tap stops copying while the FULL untruncated + # payload still goes to the primary. + def mirror(payload) + limit = @dexpace_tap_limit + headroom = limit.nil? ? payload.bytesize : limit - @dexpace_tap.bytesize + return nil if headroom <= 0 + + @dexpace_tap.write(payload.byteslice(0, headroom).to_s) + nil + end + + # IO-29: forward to the PRIMARY only, leaving the tap intact for later snapshotting. + def push_one_level + @dexpace_primary.emit if @dexpace_primary.respond_to?(:emit) + nil + end + + def push_all + @dexpace_primary.flush if @dexpace_primary.respond_to?(:flush) + nil + end + + def release + @dexpace_primary.close if @dexpace_primary.respond_to?(:close) + nil + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/io/typed_reads.rb b/gems/dexpace-core/lib/dexpace/io/typed_reads.rb new file mode 100644 index 0000000..f8bd405 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/io/typed_reads.rb @@ -0,0 +1,633 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../io" +require_relative "../error/end_of_stream_error" +require_relative "../error/stream_error" +require_relative "../error/invalid_argument_error" +require_relative "../error/closed_error" +require_relative "../error/seam_error" + +module Dexpace + module IO + # The read vocabulary, supplied over one private hook the includer defines: + # #fill(min_bytes) -> Integer, the number of bytes it appended to the store (0 at end of + # stream). Dexpace::Closeable set the initialize_*-plus-one-hook precedent in phase 2 and this + # follows it exactly; an includer must also include Dexpace::Closeable, which + # #initialize_typed_reads asserts. + # + # Public, and not private_constant, for two reasons that are each sufficient (P3-8). The + # runtime surface snapshot walks each class's public_instance_methods(false), which does NOT + # see a method reaching a class through an included module, so a private module would hide + # almost the whole of 3a from the gate that exists to see it. And a third-party source that + # supplies #fill gets the entire read vocabulary by including this, which is the Ruby shape of + # the reference's BufferedSource interface. + # + # #slice and #peek name Dexpace::IO::BufferedSource at call time rather than requiring it, + # because buffered_source.rb requires this file. lib/dexpace.rb requires the whole tree, so + # the constant is always resolvable by the time a caller can reach these methods. + module TypedReads # rubocop:disable Metrics/ModuleLength -- the whole read vocabulary by design; see above + # The line machine reads these; they are here because a constant belongs at the top. + NEWLINE = "\n".b.freeze + CARRIAGE_RETURN = "\r".b.freeze + + # The read size for the two readers that have no caller count -- #each and #drain_all. + # Symmetric to TypedWrites::WRITE_ALL_SEGMENT_BYTES and private for the same reason: it is + # not NFR-4 surface, so phase 5 is free to make it a setting without widening a public + # signature. It exists because a literal 1 here asks a wrapped stream for one byte per + # readpartial call (the plan's Task 10, 2026-09-13 amendment). + READ_SEGMENT_BYTES = 64 * 1024 + + private_constant :NEWLINE, :CARRIAGE_RETURN, :READ_SEGMENT_BYTES + + # Call from the including class's #initialize, after #initialize_closeable. + def initialize_typed_reads + unless is_a?(Dexpace::Closeable) + raise Dexpace::SeamError, + "#{self.class} includes Dexpace::IO::TypedReads and must also include " \ + "Dexpace::Closeable" + end + + @dexpace_chunks = [] #: Array[String] + @dexpace_head = 0 + @dexpace_buffered = 0 + @dexpace_consumed = 0 + @dexpace_views = [] #: Array[Dexpace::IO::TypedReads] + @dexpace_invalidated = false + nil + end + + # IO-1's primitive, and deliberately not called #read (P3-1). It APPENDS to the tail of + # `dest`; #read and #readpartial overwrite, because IO.copy_stream hands them one buffer it + # reuses across every call. + # + # Returns at least 1 when count is positive and the source is not exhausted, exactly 0 when + # count is 0 (IO-2), -1 when exhausted before any byte, and never more than count. + def read_into(dest, count:) + ensure_typed_reads_initialized + # IO-3 first, before any I/O and before the buffer is consulted. + validate_destination!(dest) + validate_count!(count, name: "count") + # IO-42 next: a closed stream-backed source rejects every read attempt, count 0 included. + # IO-2's clause is about an OPEN source's exhaustion, so it cannot swallow this. + ensure_readable + # IO-2: 0 for a zero count, never -1, without touching the buffer or the upstream. + return 0 if count.zero? + + # The caller's own count reaches the upstream, so a wrapped stream is asked for `count` + # bytes and not for one. Filling ONCE, not to `count`: blocking until `count` bytes or + # end of stream is #read(n)'s contract, not the primitive's, and IO-1 asks only for + # "at least 1 when the source is not exhausted". + fill_once_if_empty(count) + return -1 if @dexpace_buffered.zero? + + taken = [count, @dexpace_buffered].min + dest << store_take(taken) + taken + end + + # IO-12: exactly count bytes or an EOF error. Never a short result, and never a partial + # consumption -- the bytes stay buffered when it raises. + def read_exactly(count) + ensure_typed_reads_initialized + validate_count!(count, name: "count") + ensure_readable + guard_materialization!(count) + return (+"").b if count.zero? + + ensure_buffered(count) + raise_end_of_stream("read_exactly(#{count})") if @dexpace_buffered < count + + store_take(count) + end + + # IO-11/IO-16: the next byte as an unsigned 0..255, or nil at end of stream. + def getbyte + ensure_typed_reads_initialized + ensure_readable + fill_once_if_empty + return nil if @dexpace_buffered.zero? + + store_take(1).getbyte(0) + end + + # IO-11's readByte(): the next byte, or an EOF error when none remain. + def readbyte + ensure_typed_reads_initialized + ensure_readable + fill_once_if_empty + raise_end_of_stream("readbyte") if @dexpace_buffered.zero? + + store_take(1).getbyte(0) || raise_end_of_stream("readbyte") + end + + # IO-12/IO-13. `count` is a BYTE count, not a character count. With none, drains. + def read_utf8(count: nil) + read_string(::Encoding::UTF_8, count: count) + end + + # IO-13. Retags the bytes with the encoding the caller named and validates nothing: + # #valid_encoding? is the caller's question and HTTP-42's replacement policy is 3b's, at the + # one decode boundary design §3.1 permits. + def read_string(encoding, count: nil) + ensure_typed_reads_initialized + target = resolve_encoding(encoding) + ensure_readable + bytes = + if count.nil? + drain_all + else + validate_count!(count, name: "count") + guard_materialization!(count) + ensure_buffered(count) + raise_end_of_stream("read_string(#{count})") if @dexpace_buffered < count + + store_take(count) + end + bytes.force_encoding(target) + end + + # IO-15: exactly count bytes, an EOF error if fewer remain, and skip(0) a no-op even at or + # after EOF. + def skip(count) + ensure_typed_reads_initialized + validate_count!(count, name: "count") + ensure_readable + return nil if count.zero? + + ensure_buffered(count) + raise_end_of_stream("skip(#{count})") if @dexpace_buffered < count + + store_drop(count) + nil + end + + # IO-11's exhausted(). May block while the upstream decides. + def eof? + ensure_typed_reads_initialized + ensure_readable + fill_once_if_empty + @dexpace_buffered.zero? + end + + # IO-14. Hand-implemented over the byte store, never #gets: $/ is global and universal + # newline handling depends on how the IO was opened. "\n" and "\r\n" terminate, a lone "\r" + # is content, a final unterminated line comes back as-is, nil when exhausted before a byte. + # + # Unbounded on purpose (P3-4): the one drain-style read MAX_MATERIALIZED_BYTES does not + # guard, because a line has no count to check and no end but a terminator that may never + # arrive. IO-14 fixes no line length; the caller that reads lines from a hostile stream is + # phase 7's SSE machine, whose own documented cap SSE-11 requires (phase 7b's plan, Task 12). + def read_line_utf8 + ensure_typed_reads_initialized + ensure_readable + scanned = 0 + loop do + index = store_index_of(NEWLINE, scanned) + return finish_line(store_take(index), terminated: true) unless index.nil? + + scanned = @dexpace_buffered + break if ensure_buffered(scanned + 1) == scanned + end + return nil if @dexpace_buffered.zero? + + finish_line(store_take(@dexpace_buffered), terminated: false) + end + + # IO-16's host-native bulk read: Ruby's semantics exactly, so IO.copy_stream can drive it. + # It OVERWRITES outbuf where #read_into appends, keeps filling until it has `length` bytes + # or the stream ends, and returns nil at EOF for a positive length and "" at EOF with no + # length. With no length it is also IO-11's count-less byte-array read. + # + # P3-13: outbuf comes back tagged Encoding::BINARY whatever it arrived as. ::IO#read + # preserves the destination's tag on all three interpreters and StringIO#read changed at + # exactly Ruby 3.4, so Ruby's own readers disagree with each other and across this port's + # floor; pinning BINARY is what makes 3a's answer the same on every matrix row and behind + # every backing stream. IO.copy_stream does not read the tag, which is what keeps the + # bridge claim true. + def read(length = nil, outbuf = nil) + ensure_typed_reads_initialized + validate_count!(length, name: "length") unless length.nil? + ensure_readable + data = length.nil? ? drain_all : read_up_to(length) + if data.nil? + outbuf&.replace((+"").b) + return nil + end + return data if outbuf.nil? + + outbuf.replace(data) + end + + # IO-16's host-native partial read. Overwrites outbuf; raises at EOF, and the class is a + # ::EOFError subclass so IO.copy_stream terminates on it rather than propagating it. + def readpartial(maxlen, outbuf = nil) + ensure_typed_reads_initialized + validate_count!(maxlen, name: "maxlen") + ensure_readable + data = + if maxlen.zero? + (+"").b + else + fill_once_if_empty(maxlen) + raise_end_of_stream("readpartial(#{maxlen})") if @dexpace_buffered.zero? + + store_take([maxlen, @dexpace_buffered].min) + end + return data if outbuf.nil? + + outbuf.replace(data) + end + + # Design §10.2: yields BINARY chunks until exhausted, so a source IS a canonical body + # representation. On the .over path the granularity is whatever the upstream produced -- + # .over must preserve the wrapped body's own chunking for BODY-17's byte-exact mirroring to + # mean what it says. On the .wrapping path there is no caller chunking to preserve and no + # caller count to pass, so the read size is READ_SEGMENT_BYTES; a literal 1 here asked a + # wrapped stream for one byte per readpartial call (the plan's Task 10 amendment). + # + # §7.1: the resource lives on the instance and #close is on the instance, so an Enumerator + # abandoned mid-#next leaks nothing #close would not still release. + def each + return to_enum(:each) unless block_given? + + ensure_typed_reads_initialized + ensure_readable + loop do + chunk = store_take_chunk + if chunk.nil? + break if fill_once_if_empty(READ_SEGMENT_BYTES).zero? + + next + end + yield chunk + end + nil + end + + # IO-19: a non-consuming view over the whole remaining source -- a length no stream-backed + # source knows, so the window is unbounded (nil) and not "whatever happens to be buffered". + def peek + ensure_typed_reads_initialized + ensure_readable + build_view(offset: 0, window: nil) + end + + # IO-20/IO-21/IO-23. Negative offset or count is rejected eagerly; an offset past the end is + # detected LAZILY, on the first read, as an empty/EOF result. + def slice(offset:, count:) + ensure_typed_reads_initialized + validate_count!(offset, name: "offset") + validate_count!(count, name: "count") + ensure_readable + build_view(offset: offset, window: count) + end + + protected + + # The view protocol. Protected rather than public because a view drives its parent and + # nothing else may: these are not NFR-4 surface. + def dexpace_consumed + @dexpace_consumed + end + + # The two entry points a view drives its parent through check the parent's own readability + # first (IO-22, IO-42): a closed stream-backed parent, or an invalidated intermediate view, + # serves nothing to a view derived from it, however the view was reached. + def dexpace_ensure_buffered(target) + ensure_readable + ensure_buffered(target) + end + + def dexpace_window_copy(offset, count) + ensure_readable + store_peek(offset, count) + end + + def dexpace_register_view(view) + @dexpace_views << view + nil + end + + def dexpace_forget_view(view) + @dexpace_views.delete(view) + nil + end + + # Invalidation cascades: a view is itself a source with views of its own, and IO-22's + # "every outstanding slice derived from it" reaches a slice of a slice. + def dexpace_invalidate + @dexpace_invalidated = true + dexpace_release_views + end + + # IO-22/IO-42: an includer's #release MUST call this, so a close on this object invalidates + # every view derived from it and a later read on one of those views fails loudly rather than + # returning stale or arbitrary bytes. Buffer and BufferedSource both do. + # + # The block form is load-bearing: #dexpace_invalidate is protected, and `&:dexpace_invalidate` + # would send it publicly (NoMethodError: protected method called). + def dexpace_release_views + @dexpace_views.each { |view| view.dexpace_invalidate } # rubocop:disable Style/SymbolProc + @dexpace_views.clear + nil + end + + # The bytes this object can still serve when it knows -- a length-bounded view -- and nil + # when it does not, which is every root source and every peek. + def dexpace_remaining_window + nil + end + + private + + # IO-22/IO-38/IO-42. One Thread::Mutex acquisition per public entry point, through + # Closeable#closed?, and it is never held across a fill, a read, a drain or a #release. + def ensure_readable + if @dexpace_invalidated + raise Dexpace::ClosedError, + "this #{self.class} was invalidated by a close on the source it was derived from" + end + return unless closed? + return if reads_survive_close? + + raise Dexpace::ClosedError, "#{self.class} is closed" + end + + # IO-42's in-memory exemption. Only Dexpace::IO::Buffer answers true. + def reads_survive_close? + false + end + + def ensure_typed_reads_initialized + return if defined?(@dexpace_chunks) && @dexpace_chunks + + raise Dexpace::SeamError, + "#{self.class} includes Dexpace::IO::TypedReads but never called " \ + "#initialize_typed_reads" + end + + def fill(_min_bytes) + raise NotImplementedError, + "#{self.class} includes Dexpace::IO::TypedReads and must define a private " \ + "#fill(min_bytes)" + end + + # Fills ONCE, with the count its caller has. The default of 1 is for the readers that + # genuinely want one byte -- #getbyte, #readbyte and #eof?; #read_into passes its `count` + # and #readpartial its `maxlen`, so the min_bytes reaching #fill, and therefore + # readpartial(want) in BufferedSource, is the number the caller asked for. + # It calls #fill ONCE and never loops: #ensure_buffered(min_bytes) would block until + # min_bytes bytes or end of stream, which is #read(n)'s contract and not the primitive's. + def fill_once_if_empty(min_bytes = 1) + fill(min_bytes) if @dexpace_buffered.zero? + @dexpace_buffered + end + + def ensure_buffered(target) + while @dexpace_buffered < target + added = fill(target - @dexpace_buffered) + break if added.nil? || added <= 0 + end + @dexpace_buffered + end + + # THE ingress retag, and the one place it happens. Verified facts 3 and 4: force_encoding on + # a frozen String raises FrozenError even when the target encoding is already the string's + # own, and a Rack-style body's #each yields frozen literals under this repository's own + # frozen_string_literal pragma. String#b always returns a new, unfrozen copy. So: keep the + # chunk when it is both frozen and already BINARY, and #b it otherwise. force_encoding + # appears nowhere on this path. + # + # The compaction invariant this buys: every chunk in the store is a FROZEN BINARY String, so + # a chunk is copied at most once on the way in -- and exactly zero times when the upstream + # already yields frozen BINARY chunks, which is the ordinary Rack shape -- and no view can + # ever see a chunk mutated behind its back. The upstream's own return is deliberately NOT + # frozen in place: a duck-typed #readpartial that reuses one buffer would break if it were. + def store_append(chunk) + return 0 if chunk.nil? || chunk.empty? + + stored = + if chunk.frozen? && chunk.encoding == ::Encoding::BINARY + chunk + else + chunk.b.freeze + end + @dexpace_chunks << stored + @dexpace_buffered += stored.bytesize + stored.bytesize + end + + # O(1) amortised: a fully consumed chunk is shifted off, a partially consumed head chunk is + # left in place behind a moving @dexpace_head and is never bytesliced to compact it. The + # byteslice that produces the caller's bytes is the only copy those bytes get -- a read that + # fits inside the head chunk returns that one byteslice, with no second String and no `<<`. + def store_take(count) + return (+"").b if count.zero? + + out = take_from_head(count) + out << take_from_head(count - out.bytesize) while out.bytesize < count + @dexpace_buffered -= count + @dexpace_consumed += count + out + end + + # Up to `count` bytes out of the head chunk alone, as a fresh unfrozen BINARY String + # (String#byteslice of a frozen String is unfrozen, verified on 3.2.11, 3.4.10 and 4.0.6). + def take_from_head(count) + head = @dexpace_chunks.fetch(0) + available = head.bytesize - @dexpace_head + take = [count, available].min + out = head.byteslice(@dexpace_head, take) || (+"").b + advance_head(take, available) + out + end + + def store_drop(count) + remaining = count + while remaining.positive? + head = @dexpace_chunks.fetch(0) + available = head.bytesize - @dexpace_head + take = [remaining, available].min + advance_head(take, available) + remaining -= take + end + @dexpace_buffered -= count + @dexpace_consumed += count + nil + end + + def advance_head(taken, available) + if taken == available + @dexpace_chunks.shift + @dexpace_head = 0 + else + @dexpace_head += taken + end + nil + end + + # Non-consuming: copies a window `offset` bytes ahead of the cursor. This is how a view + # reads its parent without advancing the parent. A zero `count` copies nothing: the first + # chunk's (possibly empty) byteslice fills the window and the loop leaves. + def store_peek(offset, count) + out = (+"").b + skip = offset + head_offset = @dexpace_head + @dexpace_chunks.each do |chunk| + available = chunk.bytesize - head_offset + if skip < available + # byteslice clamps the length to what the chunk holds past `start`. + out << chunk.byteslice(head_offset + skip, count - out.bytesize).to_s + break if out.bytesize >= count + end + skip = [skip - available, 0].max + head_offset = 0 + end + out + end + + def validate_count!(value, name:) + unless value.is_a?(::Integer) + raise Dexpace::InvalidArgumentError, + "#{name} must be an Integer, got #{value.class}" + end + return unless value.negative? + + raise Dexpace::InvalidArgumentError, "#{name} must not be negative, got #{value}" + end + + # IO-3's eager rejection. A non-BINARY destination is rejected because appending a + # non-ASCII UTF-8 payload to a BINARY String silently retags the RESULT to UTF-8 while an + # ASCII-only one does not -- so a UTF-8 dest would produce a buffer whose tag depends on the + # payload's content. A frozen dest is rejected because a FrozenError from deep inside is a + # crash, not a rejection. + def validate_destination!(dest) + unless dest.is_a?(::String) + raise Dexpace::InvalidArgumentError, "dest must be a String, got #{dest.class}" + end + if dest.frozen? + raise Dexpace::InvalidArgumentError, "dest must not be frozen: #read_into appends to it" + end + return if dest.encoding == ::Encoding::BINARY + + raise Dexpace::InvalidArgumentError, + "dest must be tagged #{::Encoding::BINARY}, got #{dest.encoding}" + end + + def raise_end_of_stream(operation) + raise Dexpace::EndOfStreamError, + "#{operation} reached the end of the stream with #{@dexpace_buffered} " \ + "bytes buffered" + end + + # #each's granularity: the whole remaining head chunk, so the wrapped body's own chunking + # survives .over. + def store_take_chunk + return nil if @dexpace_buffered.zero? + + head = @dexpace_chunks.fetch(0) + store_take(head.bytesize - @dexpace_head) + end + + # IO-9 (P3-4). The ceiling is checked against the known count before anything is allocated, + # and against the running total on a drain, so nothing over it is ever materialised. + def drain_all + guard_materialization!(materialization_hint) + out = (+"").b + loop do + chunk = store_take_chunk + if chunk.nil? + break if fill_once_if_empty(READ_SEGMENT_BYTES).zero? + + next + end + out << chunk + guard_materialization!(out.bytesize) + end + out + end + + # The known remaining byte count when there is one -- a length-bounded view -- and nil + # otherwise. IO-9's "length-bounded slice reads apply the same guarded, message-bearing cap". + def materialization_hint + dexpace_remaining_window + end + + def guard_materialization!(count) + return if count.nil? + return if count <= Dexpace::IO::MAX_MATERIALIZED_BYTES + + raise Dexpace::StreamError, + "refusing to materialize #{count} bytes as one String: the limit is " \ + "#{Dexpace::IO::MAX_MATERIALIZED_BYTES} bytes " \ + "(Dexpace::IO::MAX_MATERIALIZED_BYTES). Stream it instead -- #read_into, #each, " \ + "#slice or Buffer#copy_to." + end + + def resolve_encoding(encoding) + return encoding if encoding.is_a?(::Encoding) + + ::Encoding.find(encoding.to_s) || + raise(Dexpace::InvalidArgumentError, "unknown encoding #{encoding.inspect}") + rescue ::ArgumentError => error + raise Dexpace::InvalidArgumentError, + "unknown encoding #{encoding.inspect}: #{error.message}" + end + + # The byte offset (from the cursor) of the first `byte` at or after `from`, searching across + # chunk boundaries and respecting a partially consumed head chunk, which is what makes the + # line rule survive a slice window. nil when the buffered bytes hold none. + def store_index_of(byte, from) + position = 0 + head_offset = @dexpace_head + @dexpace_chunks.each do |chunk| + available = chunk.bytesize - head_offset + if position + available > from + start = head_offset + (from > position ? from - position : 0) + found = chunk.byteindex(byte, start) + return position + (found - head_offset) unless found.nil? + end + position += available + head_offset = 0 + end + nil + end + + # The "\r" is stripped only when it immediately precedes the "\n" the scan found, which is + # exactly IO-14's "a lone '\r' not followed by '\n' MUST be kept". + def finish_line(bytes, terminated:) + if terminated + store_drop(1) + bytes = bytes.byteslice(0, bytes.bytesize - 1).to_s if bytes.end_with?(CARRIAGE_RETURN) + end + bytes.force_encoding(::Encoding::UTF_8) + end + + def read_up_to(length) + return (+"").b if length.zero? + + ensure_buffered(length) + return nil if @dexpace_buffered.zero? + + store_take([length, @dexpace_buffered].min) + end + + # IO-23: the child's offset composes additively (the pin is measured from this object's own + # cursor) and its window is capped at this object's remaining window, when it has one. A + # nil window is unbounded -- a peek, or a peek of a peek. + def build_view(offset:, window:) + remaining = dexpace_remaining_window + unless remaining.nil? + headroom = [remaining - offset, 0].max + window = window.nil? ? headroom : [window, headroom].min + end + view = Dexpace::IO::BufferedSource.__dexpace_view( + parent: self, pin: @dexpace_consumed + offset, window: window, + ) + dexpace_register_view(view) + view + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/io/typed_writes.rb b/gems/dexpace-core/lib/dexpace/io/typed_writes.rb new file mode 100644 index 0000000..37741bd --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/io/typed_writes.rb @@ -0,0 +1,241 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../io" +require_relative "../error/stream_error" +require_relative "../error/invalid_argument_error" +require_relative "../error/closed_error" +require_relative "../error/seam_error" + +module Dexpace + module IO + # The write vocabulary, symmetric to TypedReads and supplied over one private hook the + # includer defines: #deliver(string) -> void, which pushes BINARY bytes one level toward the + # includer's destination. An includer must also include Dexpace::Closeable. + # + # Public and not private_constant for TypedReads' two reasons (P3-8). + # + # It carries its own #validate_count! and #resolve_encoding rather than sharing TypedReads', + # because each module must stand alone: a third-party sink that supplies #deliver gets the + # whole write vocabulary by including this one and nothing else. + # + # The hook's contract, stated once: everything handed to #deliver is a FROZEN BINARY String. + # #binary_of, #encode_to, #write_all and #write_from each freeze what they produce, so a + # Buffer's #store_append keeps it with no second copy and a caller that reuses its own buffer + # cannot reach what was already written. + module TypedWrites # rubocop:disable Metrics/ModuleLength -- the whole write vocabulary by design; see above + # The pump's read granularity. Private, so it is not NFR-4 surface and phase 5 is free to + # make it a setting without widening a public signature. + WRITE_ALL_SEGMENT_BYTES = 64 * 1024 + + private_constant :WRITE_ALL_SEGMENT_BYTES + + # Call from the including class's #initialize, after #initialize_closeable. + def initialize_typed_writes + unless is_a?(Dexpace::Closeable) + raise Dexpace::SeamError, + "#{self.class} includes Dexpace::IO::TypedWrites and must also include " \ + "Dexpace::Closeable" + end + + @dexpace_writes_ready = true + nil + end + + # IO-4: removes exactly `count` bytes from the HEAD of `buffer` and pushes them downstream. + # If the buffer holds fewer, this fails with an I/O error rather than writing a short amount + # -- and it consumes nothing, so the buffer is still usable after the rejection. + def write_from(buffer, count:) + ensure_typed_writes_initialized + validate_count!(count, name: "count") + unless buffer.is_a?(Dexpace::IO::Buffer) + raise Dexpace::InvalidArgumentError, + "write_from takes a Dexpace::IO::Buffer, got #{buffer.class}" + end + + ensure_writable + available = buffer.bytesize + if available < count + raise Dexpace::StreamError.short_transfer(transferred: available, expected: count) + end + return nil if count.zero? + + deliver(buffer.read_exactly(count).freeze) + nil + end + + # IO-16's host-native writable bridge: returns the byte count, which is what + # IO.copy_stream requires of a destination. Positional splat, exactly like ::IO#write -- + # P3-9's stated exception, because a keyword here would make IO.copy_stream fail. + def write(*strings) + ensure_typed_writes_initialized + ensure_writable + total = 0 + strings.each do |string| + bytes = binary_of(string) + next if bytes.empty? + + deliver(bytes) + total += bytes.bytesize + end + total + end + + # IO-17: pumps `source` to exhaustion through IO-1's primitive and returns the total. + # It terminates ONLY on a -1 read, and a read of 0 for a positive requested count is a + # source-contract violation raised as an I/O error -- never tolerated as EOF, never spun on. + # + # The check is unconditional rather than scoped to a "foreign" source: design §10.1 retired + # the adapter, so "adapter-native" has no subject in this port, and a correct source never + # returns 0 for a positive count anyway. + def write_all(source) + ensure_typed_writes_initialized + unless source.respond_to?(:read_into) + raise Dexpace::InvalidArgumentError, + "write_all takes a source responding to #read_into, got #{source.class}" + end + + ensure_writable + total = 0 + loop do + chunk = (+"").b + transferred = source.read_into(chunk, count: WRITE_ALL_SEGMENT_BYTES) + break if transferred == -1 + + if transferred.zero? + raise Dexpace::StreamError.zero_read(requested: WRITE_ALL_SEGMENT_BYTES) + end + + deliver(chunk.freeze) + total += transferred + end + total + end + + # IO-13. `range` is a CHARACTER range over the String, the reference's substring form. + def write_utf8(string, range: nil) + write_string(range.nil? ? string : sliced(string, range), encoding: ::Encoding::UTF_8) + end + + # IO-13's symmetric explicit-charset write. + def write_string(string, encoding:) + ensure_typed_writes_initialized + unless string.is_a?(::String) + raise Dexpace::InvalidArgumentError, "string must be a String, got #{string.class}" + end + + target = resolve_encoding(encoding) + ensure_writable + bytes = encode_to(string, target) + deliver(bytes) unless bytes.empty? + nil + end + + # IO-5/IO-18. #emit is the cheap one-level hand-off -- staging buffer to underlying stream -- + # and does NOT force a system-level flush; #flush forces bytes all the way out. On a pure + # in-memory buffer both are the no-ops IO-18 explicitly permits. + def emit + ensure_typed_writes_initialized + ensure_writable + push_one_level + self + end + + # IO-5: pushes what is staged all the way out, the underlying stream's own #flush included. + def flush + ensure_typed_writes_initialized + ensure_writable + push_all + self + end + + private + + # IO-42, through Closeable#closed? -- one Thread::Mutex acquisition per public entry point. + def ensure_writable + return unless closed? + return if writes_survive_close? + + raise Dexpace::ClosedError, "#{self.class} is closed" + end + + # IO-42's in-memory exemption. Only Dexpace::IO::Buffer answers true. + def writes_survive_close? + false + end + + def ensure_typed_writes_initialized + return if defined?(@dexpace_writes_ready) && @dexpace_writes_ready + + raise Dexpace::SeamError, + "#{self.class} includes Dexpace::IO::TypedWrites but never called " \ + "#initialize_typed_writes" + end + + def deliver(_string) + raise NotImplementedError, + "#{self.class} includes Dexpace::IO::TypedWrites and must define a private " \ + "#deliver(string)" + end + + def push_one_level + nil + end + + def push_all + push_one_level + end + + def sliced(string, range) + unless string.is_a?(::String) + raise Dexpace::InvalidArgumentError, "string must be a String, got #{string.class}" + end + + part = string[range] + if part.nil? + raise Dexpace::InvalidArgumentError, "range #{range.inspect} is outside the string" + end + + part + end + + def encode_to(string, target) + string.encode(target).b.freeze + rescue ::EncodingError => error + raise Dexpace::InvalidArgumentError, + "cannot encode the given String as #{target}: #{error.message}" + end + + def binary_of(string) + unless string.is_a?(::String) + raise Dexpace::InvalidArgumentError, "write takes Strings, got #{string.class}" + end + + return string if string.frozen? && string.encoding == ::Encoding::BINARY + + # Frozen, so the store keeps it without a second copy and a caller that reuses its own + # buffer cannot reach what was already written. + string.b.freeze + end + + def validate_count!(value, name:) + unless value.is_a?(::Integer) + raise Dexpace::InvalidArgumentError, "#{name} must be an Integer, got #{value.class}" + end + return unless value.negative? + + raise Dexpace::InvalidArgumentError, "#{name} must not be negative, got #{value}" + end + + def resolve_encoding(encoding) + return encoding if encoding.is_a?(::Encoding) + + ::Encoding.find(encoding.to_s) || + raise(Dexpace::InvalidArgumentError, "unknown encoding #{encoding.inspect}") + rescue ::ArgumentError => error + raise Dexpace::InvalidArgumentError, + "unknown encoding #{encoding.inspect}: #{error.message}" + end + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/error/end_of_stream_error.rbs b/gems/dexpace-core/sig/dexpace/error/end_of_stream_error.rbs new file mode 100644 index 0000000..b0daf2c --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/error/end_of_stream_error.rbs @@ -0,0 +1,5 @@ +module Dexpace + class EndOfStreamError < ::EOFError + include Dexpace::Error + end +end diff --git a/gems/dexpace-core/sig/dexpace/error/stream_error.rbs b/gems/dexpace-core/sig/dexpace/error/stream_error.rbs new file mode 100644 index 0000000..1d726ea --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/error/stream_error.rbs @@ -0,0 +1,8 @@ +module Dexpace + class StreamError < ::IOError + include Dexpace::Error + + def self.short_transfer: (transferred: Integer, expected: Integer) -> Dexpace::StreamError + def self.zero_read: (requested: Integer) -> Dexpace::StreamError + end +end diff --git a/gems/dexpace-core/sig/dexpace/io.rbs b/gems/dexpace-core/sig/dexpace/io.rbs new file mode 100644 index 0000000..c75757c --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/io.rbs @@ -0,0 +1,23 @@ +module Dexpace + module IO + MAX_MATERIALIZED_BYTES: Integer + + # IO-1's primitive, as a type. #write_all accepts anything satisfying it (IO-17). + interface _Source + def read_into: (String dest, count: Integer) -> Integer + end + + # The primitive a destination satisfies, and what IO.copy_stream requires of one. + interface _Sink + def write: (*String) -> Integer + end + + # Design §10.2's canonical body representation, AS A TYPE. Deliberately not named _Body: 3b's + # body type is richer -- media type, content length, #replayable?, a single write-to-sink + # operation -- and one name for two meanings across the sub-phase cut is the failure the cut + # exists to prevent. 3b's interface can include this one. + interface _Chunked + def each: () { (String) -> void } -> void + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/io/buffer.rbs b/gems/dexpace-core/sig/dexpace/io/buffer.rbs new file mode 100644 index 0000000..f048f07 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/io/buffer.rbs @@ -0,0 +1,24 @@ +module Dexpace + module IO + class Buffer + include Dexpace::IO::TypedReads + include Dexpace::IO::TypedWrites + include Dexpace::Closeable + + def initialize: () -> void + def bytesize: () -> Integer + def snapshot: () -> String + def clear: () -> nil + def copy_to: (Dexpace::IO::Buffer other, ?offset: Integer, ?count: Integer?) -> nil + + private + + def validate_window!: (Integer offset, Integer? count) -> Integer + def fill: (Integer min_bytes) -> Integer + def deliver: (String string) -> nil + def reads_survive_close?: () -> bool + def writes_survive_close?: () -> bool + def release: () -> nil + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/io/buffered_sink.rbs b/gems/dexpace-core/sig/dexpace/io/buffered_sink.rbs new file mode 100644 index 0000000..a480a75 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/io/buffered_sink.rbs @@ -0,0 +1,27 @@ +module Dexpace + module IO + class BufferedSink + include Dexpace::IO::TypedWrites + include Dexpace::Closeable + + # `io` is untyped on purpose: it accepts anything responding to #write, and the runtime + # check is a duck test. + def self.wrapping: (untyped io) -> Dexpace::IO::BufferedSink + | [T] (untyped io) { (Dexpace::IO::BufferedSink) -> T } -> T + + def initialize: (untyped io) -> void + + private + + @dexpace_underlying: untyped + @dexpace_staged: String + + def deliver: (String string) -> nil + def push_one_level: () -> nil + def write_through: (String payload) -> untyped + def check_full_write: (untyped reported, Integer expected) -> nil + def push_all: () -> nil + def release: () -> nil + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/io/buffered_source.rbs b/gems/dexpace-core/sig/dexpace/io/buffered_source.rbs new file mode 100644 index 0000000..e11dfb4 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/io/buffered_source.rbs @@ -0,0 +1,51 @@ +module Dexpace + module IO + class BufferedSource + include Dexpace::IO::TypedReads + include Dexpace::Closeable + + # A private_constant with no visibility in RBS, declared so Steep can type the view path. + class View < Data + attr_reader parent: Dexpace::IO::TypedReads + attr_reader pin: Integer + attr_reader window: Integer? + + def self.new: (parent: Dexpace::IO::TypedReads, pin: Integer, window: Integer?) -> instance + end + + # `io` is untyped on purpose: it accepts anything responding to #readpartial OR #read, and + # an RBS union of two structural interfaces would claim a narrowness the runtime check + # does not have. + def self.wrapping: (untyped io) -> Dexpace::IO::BufferedSource + | [T] (untyped io) { (Dexpace::IO::BufferedSource) -> T } -> T + def self.of_bytes: (String string) -> Dexpace::IO::BufferedSource + # `chunked` is untyped for the same reason: the runtime test is a duck test, and _Chunked + # names the shape a consumer's own steep check can type its bodies against. + def self.over: (untyped chunked) -> Dexpace::IO::BufferedSource + def self.__dexpace_view: (parent: Dexpace::IO::TypedReads, pin: Integer, window: Integer?) -> Dexpace::IO::BufferedSource + + def initialize: (?upstream: untyped, ?owns_upstream: bool, ?chunked: untyped, ?view: View?) -> void + def view?: () -> bool + def owns_upstream?: () -> bool + + private + + @dexpace_upstream: untyped + @dexpace_owns_upstream: bool + @dexpace_enumerator: Enumerator[String, untyped]? + @dexpace_view: View? + @dexpace_pulled: Integer + @dexpace_upstream_exhausted: bool + + def freeze_construction_facts: () -> nil + def fill: (Integer min_bytes) -> Integer + def fill_from_upstream: (Integer min_bytes) -> Integer + def pull_from_upstream: (Integer want) -> String? + def fill_from_enumerator: (Enumerator[String, untyped] enumerator) -> Integer + def window_budget: (View view, Integer min_bytes) -> Integer + def fill_from_parent: (View view, Integer min_bytes) -> Integer + def offset_into_parent: (View view, Dexpace::IO::TypedReads parent) -> Integer + def release: () -> nil + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/io/tee_sink.rbs b/gems/dexpace-core/sig/dexpace/io/tee_sink.rbs new file mode 100644 index 0000000..2461333 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/io/tee_sink.rbs @@ -0,0 +1,32 @@ +module Dexpace + module IO + class TeeSink + include Dexpace::IO::TypedWrites + include Dexpace::Closeable + + # `primary` is untyped for BufferedSink.wrapping's reason: anything responding to #write, + # checked at runtime as a duck. `tap_limit` is a non-negative Integer, nil for unbounded, + # or Float::INFINITY as the other spelling of unbounded. + def initialize: (primary: untyped, ?tap_limit: (Integer | Float)?) -> void + def tap_snapshot: () -> String + def tap_bytesize: () -> Integer + def buffer: () -> bot + + private + + @dexpace_primary: untyped + @dexpace_tap_limit: Integer? + @dexpace_tap: Dexpace::IO::Buffer + @dexpace_staged: String + + def validated_tap_limit: (untyped limit) -> Integer? + def deliver: (String string) -> nil + def forward: (String payload) -> untyped + def check_full_write: (untyped reported, Integer expected) -> nil + def mirror: (String payload) -> nil + def push_one_level: () -> nil + def push_all: () -> nil + def release: () -> nil + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/io/typed_reads.rbs b/gems/dexpace-core/sig/dexpace/io/typed_reads.rbs new file mode 100644 index 0000000..ea64f38 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/io/typed_reads.rbs @@ -0,0 +1,77 @@ +module Dexpace + module IO + module TypedReads + # Three private_constants with no visibility in RBS, declared so Steep can type the store. + NEWLINE: String + CARRIAGE_RETURN: String + READ_SEGMENT_BYTES: Integer + + def initialize_typed_reads: () -> nil + def read_into: (String dest, count: Integer) -> Integer + def read: (?Integer? length, ?String? outbuf) -> String? + def readpartial: (Integer maxlen, ?String? outbuf) -> String + def read_exactly: (Integer count) -> String + def getbyte: () -> Integer? + def readbyte: () -> Integer + def read_utf8: (?count: Integer?) -> String + def read_string: (Encoding | String encoding, ?count: Integer?) -> String + def read_line_utf8: () -> String? + def skip: (Integer count) -> nil + def eof?: () -> bool + def each: () { (String) -> void } -> nil + | () -> Enumerator[String, nil] + def peek: () -> Dexpace::IO::BufferedSource + def slice: (offset: Integer, count: Integer) -> Dexpace::IO::BufferedSource + + # The view protocol: protected in lib/, so a view drives its parent and nothing else may. + def dexpace_consumed: () -> Integer + def dexpace_ensure_buffered: (Integer target) -> Integer + def dexpace_window_copy: (Integer offset, Integer count) -> String + def dexpace_register_view: (Dexpace::IO::TypedReads view) -> nil + def dexpace_forget_view: (Dexpace::IO::TypedReads view) -> nil + def dexpace_invalidate: () -> nil + def dexpace_release_views: () -> nil + def dexpace_remaining_window: () -> Integer? + + private + + # Prefixed so an including class's own state cannot collide with the store. + @dexpace_chunks: Array[String] + @dexpace_head: Integer + @dexpace_buffered: Integer + @dexpace_consumed: Integer + @dexpace_views: Array[Dexpace::IO::TypedReads] + @dexpace_invalidated: bool + + # The one hook an includer supplies. + def fill: (Integer min_bytes) -> Integer + + # Closeable's, which an includer must also include (asserted at #initialize_typed_reads). + def closed?: () -> bool + + def ensure_readable: () -> void + def reads_survive_close?: () -> bool + def ensure_typed_reads_initialized: () -> void + def fill_once_if_empty: (?Integer min_bytes) -> Integer + def ensure_buffered: (Integer target) -> Integer + def store_append: (String? chunk) -> Integer + def store_take: (Integer count) -> String + def take_from_head: (Integer count) -> String + def store_take_chunk: () -> String? + def store_drop: (Integer count) -> nil + def advance_head: (Integer taken, Integer available) -> nil + def store_peek: (Integer offset, Integer count) -> String + def store_index_of: (String byte, Integer from) -> Integer? + def finish_line: (String bytes, terminated: bool) -> String + def drain_all: () -> String + def read_up_to: (Integer length) -> String? + def materialization_hint: () -> Integer? + def guard_materialization!: (Integer? count) -> void + def resolve_encoding: (Encoding | String encoding) -> Encoding + def build_view: (offset: Integer, window: Integer?) -> Dexpace::IO::BufferedSource + def validate_count!: (untyped value, name: String) -> void + def validate_destination!: (untyped dest) -> void + def raise_end_of_stream: (String operation) -> bot + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/io/typed_writes.rbs b/gems/dexpace-core/sig/dexpace/io/typed_writes.rbs new file mode 100644 index 0000000..e868335 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/io/typed_writes.rbs @@ -0,0 +1,40 @@ +module Dexpace + module IO + module TypedWrites + # A private_constant with no visibility in RBS, declared so Steep can type the pump. + WRITE_ALL_SEGMENT_BYTES: Integer + + def initialize_typed_writes: () -> nil + def write_from: (Dexpace::IO::Buffer buffer, count: Integer) -> nil + def write: (*String strings) -> Integer + # `source` is untyped for BufferedSource.over's reason: the runtime test is a duck test, and + # _Source names the shape a consumer's own steep check can type a source against. + def write_all: (untyped source) -> Integer + def write_utf8: (String string, ?range: Range[Integer]?) -> nil + def write_string: (String string, encoding: Encoding | String) -> nil + def emit: () -> self + def flush: () -> self + + private + + @dexpace_writes_ready: bool + + # The one hook an includer supplies. + def deliver: (String string) -> void + + # Closeable's, which an includer must also include (asserted at #initialize_typed_writes). + def closed?: () -> bool + + def ensure_writable: () -> void + def writes_survive_close?: () -> bool + def ensure_typed_writes_initialized: () -> void + def push_one_level: () -> nil + def push_all: () -> nil + def sliced: (untyped string, Range[Integer] range) -> String + def encode_to: (String string, Encoding target) -> String + def binary_of: (untyped string) -> String + def validate_count!: (untyped value, name: String) -> void + def resolve_encoding: (Encoding | String encoding) -> Encoding + end + end +end From a42c29ef3312c11f0286eb1866194dd6a4853e91 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Tue, 15 Sep 2026 20:56:48 +0300 Subject: [PATCH 3/8] chore: admit three core names to the NFR-11 scan; regenerate the manifest tools/rbs_surface.rb's stdlib allowlist gains EOFError and IOError -- the superclasses IO-16 and IO-4/IO-17/IO-42 give the two stream failure types -- and Encoding, which IO-13's charset reads take: core Ruby names phase 0's list never met, the false positive phase 1 fixed for Data, ArgumentError and StringScanner, with a fixture the gate test pins. The runtime surface manifest of dexpace-core grows from 318 to 367 lines, exactly the public constants and methods the nine files define; no private_constant appears, BufferedSource.__dexpace_view does under its underscore name. The core smoke suite's constant list gains the streaming layer and pins that Dexpace::IOError and Dexpace::EOFError are never defined. --- gems/dexpace-core/test/dexpace_test.rb | 32 +++++++++--- .../gems/fixture/sig/io_superclasses.rbs | 16 ++++++ test/fixtures/surface/dexpace-core.txt | 49 +++++++++++++++++++ tools/rbs_surface.rb | 11 +++-- 4 files changed, 96 insertions(+), 12 deletions(-) create mode 100644 test/fixtures/gates/rbs_surface/gems/fixture/sig/io_superclasses.rbs diff --git a/gems/dexpace-core/test/dexpace_test.rb b/gems/dexpace-core/test/dexpace_test.rb index 71de071..7c013ae 100644 --- a/gems/dexpace-core/test/dexpace_test.rb +++ b/gems/dexpace-core/test/dexpace_test.rb @@ -31,8 +31,9 @@ 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. 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). + # to lib/ and forgotten in the entry point. Phase 1's domain model, then phase 2's seam layer, + # then phase 3a's byte-streaming 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 @@ -41,10 +42,11 @@ class DexpaceTest < DexpaceTestCase SeamError ClosedError CancelledError Closeable Cancellation Async Registry Bridge Transport AsyncTransport Serde Operation ].freeze + IO_LAYER = %i[StreamError EndOfStreamError IO].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, *SEAM_LAYER], + assert_empty(NAMESPACE_ADDED - [:VERSION, *DOMAIN_MODEL, *SEAM_LAYER, *IO_LAYER], "constants added under Dexpace",) assert_includes(Dexpace.constants(false), :VERSION) end @@ -58,10 +60,10 @@ class DexpaceTest < DexpaceTestCase end test "every constant the manifest records is reachable from Dexpace" do - (DOMAIN_MODEL + SEAM_LAYER).each do |name| + (DOMAIN_MODEL + SEAM_LAYER + IO_LAYER).each do |name| assert(Dexpace.const_defined?(name, false), "#{name} missing") end - assert_empty((DOMAIN_MODEL + SEAM_LAYER) - Dexpace.constants(false)) + assert_empty((DOMAIN_MODEL + SEAM_LAYER + IO_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 @@ -81,10 +83,24 @@ class DexpaceTest < DexpaceTestCase 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 + # A consumer requires "dexpace" and nothing else: the byte-streaming layer resolves too + # (phase 3a). + test "requiring dexpace alone makes the whole streaming layer resolve" do + assert_equal(Dexpace::IO::Buffer, Dexpace::IO.const_get(:Buffer)) + assert_equal(Dexpace::IO::BufferedSource, Dexpace::IO.const_get(:BufferedSource)) + assert_equal(Dexpace::IO::BufferedSink, Dexpace::IO.const_get(:BufferedSink)) + assert_equal(Dexpace::IO::TeeSink, Dexpace::IO.const_get(:TeeSink)) + assert_equal(Dexpace::StreamError, Dexpace.const_get(:StreamError)) + assert_equal(Dexpace::EndOfStreamError, Dexpace.const_get(:EndOfStreamError)) + end + + # The shadowing names this SDK never defines: each would make a bare `rescue ArgumentError`, + # `rescue IOError` or `rescue EOFError` inside `module Dexpace` stop catching Ruby's own + # (deviation P1-3; phase 3a's design for the two I/O names). + test "never defines Dexpace::ArgumentError, Dexpace::IOError or Dexpace::EOFError" do refute_includes(Dexpace.constants(false), :ArgumentError) + refute_includes(Dexpace.constants(false), :IOError) + refute_includes(Dexpace.constants(false), :EOFError) end # Dexpace::Method shadows ::Method only inside core (the entry file's YARD block says so); diff --git a/test/fixtures/gates/rbs_surface/gems/fixture/sig/io_superclasses.rbs b/test/fixtures/gates/rbs_surface/gems/fixture/sig/io_superclasses.rbs new file mode 100644 index 0000000..b7a4fc3 --- /dev/null +++ b/test/fixtures/gates/rbs_surface/gems/fixture/sig/io_superclasses.rbs @@ -0,0 +1,16 @@ +# Phase 3a's shapes: the two stream failure types subclass core Ruby's EOFError and IOError +# (P3-3), and IO-13's charset reads take an Encoding. All three are core Ruby and need no +# require; the allowlist as phase 1 left it named none of them. +module Dexpace + class EndOfStreamError < ::EOFError + end + + class StreamError < ::IOError + end + + module IO + module TypedReads + def read_string: (Encoding | String encoding, ?count: Integer?) -> String + end + end +end diff --git a/test/fixtures/surface/dexpace-core.txt b/test/fixtures/surface/dexpace-core.txt index 16173f7..422f086 100644 --- a/test/fixtures/surface/dexpace-core.txt +++ b/test/fixtures/surface/dexpace-core.txt @@ -72,6 +72,7 @@ Dexpace::Closeable#closed? Dexpace::Closeable#initialize_closeable Dexpace::Closeable#owned? Dexpace::ClosedError +Dexpace::EndOfStreamError Dexpace::Error Dexpace::HeaderName Dexpace::HeaderName#== @@ -122,6 +123,51 @@ Dexpace::Headers::Builder#set Dexpace::Headers::DIRECTIONS : Array Dexpace::Headers::EMPTY : Dexpace::Headers Dexpace::Headers::EMPTY_INBOUND : Dexpace::Headers +Dexpace::IO +Dexpace::IO::Buffer +Dexpace::IO::Buffer#bytesize +Dexpace::IO::Buffer#clear +Dexpace::IO::Buffer#copy_to +Dexpace::IO::Buffer#snapshot +Dexpace::IO::BufferedSink +Dexpace::IO::BufferedSink.wrapping +Dexpace::IO::BufferedSource +Dexpace::IO::BufferedSource#owns_upstream? +Dexpace::IO::BufferedSource#view? +Dexpace::IO::BufferedSource.__dexpace_view +Dexpace::IO::BufferedSource.of_bytes +Dexpace::IO::BufferedSource.over +Dexpace::IO::BufferedSource.wrapping +Dexpace::IO::MAX_MATERIALIZED_BYTES : Integer +Dexpace::IO::TeeSink +Dexpace::IO::TeeSink#buffer +Dexpace::IO::TeeSink#tap_bytesize +Dexpace::IO::TeeSink#tap_snapshot +Dexpace::IO::TypedReads +Dexpace::IO::TypedReads#each +Dexpace::IO::TypedReads#eof? +Dexpace::IO::TypedReads#getbyte +Dexpace::IO::TypedReads#initialize_typed_reads +Dexpace::IO::TypedReads#peek +Dexpace::IO::TypedReads#read +Dexpace::IO::TypedReads#read_exactly +Dexpace::IO::TypedReads#read_into +Dexpace::IO::TypedReads#read_line_utf8 +Dexpace::IO::TypedReads#read_string +Dexpace::IO::TypedReads#read_utf8 +Dexpace::IO::TypedReads#readbyte +Dexpace::IO::TypedReads#readpartial +Dexpace::IO::TypedReads#skip +Dexpace::IO::TypedReads#slice +Dexpace::IO::TypedWrites +Dexpace::IO::TypedWrites#emit +Dexpace::IO::TypedWrites#flush +Dexpace::IO::TypedWrites#initialize_typed_writes +Dexpace::IO::TypedWrites#write +Dexpace::IO::TypedWrites#write_all +Dexpace::IO::TypedWrites#write_from +Dexpace::IO::TypedWrites#write_string +Dexpace::IO::TypedWrites#write_utf8 Dexpace::InvalidArgumentError Dexpace::MediaType Dexpace::MediaType#charset @@ -304,6 +350,9 @@ 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::StreamError +Dexpace::StreamError.short_transfer +Dexpace::StreamError.zero_read Dexpace::Transport Dexpace::Transport.async_over Dexpace::Transport.conforms? diff --git a/tools/rbs_surface.rb b/tools/rbs_surface.rb index bb8bbd6..e100a78 100644 --- a/tools/rbs_surface.rb +++ b/tools/rbs_surface.rb @@ -20,11 +20,14 @@ module RbsSurface # Core Ruby and the stdlib names the allowlist in RequireAllowlist admits. `Data` is the base # of every core model (design §4), `ArgumentError` is the superclass HTTP-47 gives the # validation error (phase 1, P1-3) and `StringScanner` is `strscan`, which the require - # allowlist admits; all three were added when the first real signatures needed them. + # allowlist admits; all three were added when the first real signatures needed them. Phase 3a + # added `EOFError` and `IOError` -- the superclasses IO-16 and IO-4/IO-17/IO-42 give the two + # stream failure types (P3-3), both core Ruby -- and `Encoding`, which IO-13's charset reads + # take, for the same reason. STDLIB_ALLOWED = %w[ - ArgumentError Array Bool Class Comparable Data Enumerable Enumerator Exception Float Hash IO - Integer Method Module Mutex Numeric Object Proc Range Rational Regexp Set StandardError - String StringIO StringScanner Symbol Thread Time URI + ArgumentError Array Bool Class Comparable Data Encoding Enumerable Enumerator EOFError + Exception Float Hash IO IOError Integer Method Module Mutex Numeric Object Proc Range + Rational Regexp Set StandardError String StringIO StringScanner Symbol Thread Time URI ].freeze # The type constructors that carry a name. RBS::Types::Variable does not: `T` is a parameter. From 2e18bb555c445188cb15b53f0a13aeb8df37b3df Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Tue, 15 Sep 2026 21:54:38 +0300 Subject: [PATCH 4/8] fix: a view fills its parent once, and the view constructor is private Through #peek and #slice, #fill_from_parent asked the parent to buffer `behind + want` bytes, so a view's #read_into, #readpartial and #each blocked until the whole requested count had arrived: a peek over a pipe holding 10 bytes with the writer still open hung on read_into(count: 100) where the root returned the 10 at once. The design's R2 fixes the primitive at one fill and R4 says the bridge claim holds for views, so the parent-side entry point is now #dexpace_fill_beyond(behind, want), which fills until one byte past `behind` is buffered or the upstream is done, asking for the view's own count each time, and the view takes what is there. #read(n) on a view still blocks to n through the view's own #ensure_buffered loop, which is Ruby's contract (review round 0, R0-1). BufferedSource.__dexpace_view becomes a private class method with a private RBS declaration, reached from TypedReads#build_view through #send as Headers#== reaches #values, so the underscore name leaves the surface manifest before the first tag locks it (R0-3). The two RBS declarations over 100 columns are wrapped in the sig's own multi-line shape (R0-5). --- .../lib/dexpace/io/buffered_source.rb | 18 +++++++++++--- .../lib/dexpace/io/typed_reads.rb | 24 +++++++++++++++---- .../sig/dexpace/io/buffered_source.rbs | 10 ++++++-- .../sig/dexpace/io/typed_reads.rbs | 2 +- test/fixtures/surface/dexpace-core.txt | 1 - 5 files changed, 44 insertions(+), 11 deletions(-) diff --git a/gems/dexpace-core/lib/dexpace/io/buffered_source.rb b/gems/dexpace-core/lib/dexpace/io/buffered_source.rb index b3bc540..c8bee09 100644 --- a/gems/dexpace-core/lib/dexpace/io/buffered_source.rb +++ b/gems/dexpace-core/lib/dexpace/io/buffered_source.rb @@ -97,12 +97,19 @@ def self.over(chunked) new(chunked: chunked) end - # The view constructor #peek and #slice reach. Not public API: a view is a BufferedSource in - # view mode, which makes IO-23's slice-of-a-slice free and adds no NFR-4-locked constant. + # The view constructor #peek and #slice reach. Internal, and kept so: a view is a + # BufferedSource in view mode, which makes IO-23's slice-of-a-slice free and adds no + # NFR-4-locked constant. It is a private class method with a private RBS declaration, so it + # sits in neither the surface manifest nor the sig diff; its one caller, + # TypedReads#build_view, lives outside this class and reaches it through #send, as + # Headers#== reaches #values -- the hole design §4's P8 records, used from inside core + # rather than opened as a door. def self.__dexpace_view(parent:, pin:, window:) new(view: View.new(parent: parent, pin: pin, window: window)) end + private_class_method :__dexpace_view + def initialize(upstream: nil, owns_upstream: false, chunked: nil, view: nil) @dexpace_upstream = upstream @dexpace_owns_upstream = owns_upstream @@ -207,13 +214,18 @@ def fill_from_enumerator(enumerator) # parent's #fill without advancing the parent's cursor. If the parent consumes past the # window, the view's later reads fail loudly rather than returning bytes from somewhere # else -- IO-22's "never returning stale or arbitrary bytes" is the anchor. + # + # One fill, like the root's: the parent fills until it holds a byte past `behind` or is + # done, and this view takes what is there up to `want`. Asking the parent to buffer + # `behind + want` instead would block a view's #read_into, #readpartial and #each until the + # whole count arrived, which a root never does (review round 0, R0-1). def fill_from_parent(view, min_bytes) want = window_budget(view, min_bytes) return 0 if want.zero? parent = view.parent behind = offset_into_parent(view, parent) - available = parent.dexpace_ensure_buffered(behind + want) - behind + available = parent.dexpace_fill_beyond(behind, want) - behind return 0 if available <= 0 take = [available, want].min diff --git a/gems/dexpace-core/lib/dexpace/io/typed_reads.rb b/gems/dexpace-core/lib/dexpace/io/typed_reads.rb index f8bd405..487b29c 100644 --- a/gems/dexpace-core/lib/dexpace/io/typed_reads.rb +++ b/gems/dexpace-core/lib/dexpace/io/typed_reads.rb @@ -294,9 +294,25 @@ def dexpace_consumed # The two entry points a view drives its parent through check the parent's own readability # first (IO-22, IO-42): a closed stream-backed parent, or an invalidated intermediate view, # serves nothing to a view derived from it, however the view was reached. - def dexpace_ensure_buffered(target) + # + # This one is a view's fill seen from the parent's side, and it keeps the primitive's + # one-fill contract through #peek and #slice: fill until at least one byte past `behind` -- + # the byte the view is waiting for -- is buffered, or the upstream is done, and never to + # `behind + want`. A root's #readpartial blocks for its first byte and returns whatever + # arrived with it; a view over the same stream does exactly that and no more, so IO-1's + # primitive and IO-16's host-native bridge return what is there through a view as they do + # on the root, and #read(n) on a view blocks to n only because the view's own + # #ensure_buffered loops over this. Each fill asks the upstream for the rest of what the + # view wants, so the count reaching readpartial is the view's own and never a 1 (the plan's + # Task 10 amendment holds through a view). Returns this object's buffered total, for the + # caller to subtract `behind` from. + def dexpace_fill_beyond(behind, want) ensure_readable - ensure_buffered(target) + while @dexpace_buffered <= behind + added = fill(behind + want - @dexpace_buffered) + break if added.nil? || added <= 0 + end + @dexpace_buffered end def dexpace_window_copy(offset, count) @@ -622,8 +638,8 @@ def build_view(offset:, window:) headroom = [remaining - offset, 0].max window = window.nil? ? headroom : [window, headroom].min end - view = Dexpace::IO::BufferedSource.__dexpace_view( - parent: self, pin: @dexpace_consumed + offset, window: window, + view = Dexpace::IO::BufferedSource.send( + :__dexpace_view, parent: self, pin: @dexpace_consumed + offset, window: window, ) dexpace_register_view(view) view diff --git a/gems/dexpace-core/sig/dexpace/io/buffered_source.rbs b/gems/dexpace-core/sig/dexpace/io/buffered_source.rbs index e11dfb4..2032dfa 100644 --- a/gems/dexpace-core/sig/dexpace/io/buffered_source.rbs +++ b/gems/dexpace-core/sig/dexpace/io/buffered_source.rbs @@ -22,9 +22,15 @@ module Dexpace # `chunked` is untyped for the same reason: the runtime test is a duck test, and _Chunked # names the shape a consumer's own steep check can type its bodies against. def self.over: (untyped chunked) -> Dexpace::IO::BufferedSource - def self.__dexpace_view: (parent: Dexpace::IO::TypedReads, pin: Integer, window: Integer?) -> Dexpace::IO::BufferedSource + # The view constructor: private_class_method in lib/, private here, so neither the surface + # manifest nor the sig diff carries it; declared because strict Steep types its body. + private def self.__dexpace_view: ( + parent: Dexpace::IO::TypedReads, pin: Integer, window: Integer?, + ) -> Dexpace::IO::BufferedSource - def initialize: (?upstream: untyped, ?owns_upstream: bool, ?chunked: untyped, ?view: View?) -> void + def initialize: ( + ?upstream: untyped, ?owns_upstream: bool, ?chunked: untyped, ?view: View?, + ) -> void def view?: () -> bool def owns_upstream?: () -> bool diff --git a/gems/dexpace-core/sig/dexpace/io/typed_reads.rbs b/gems/dexpace-core/sig/dexpace/io/typed_reads.rbs index ea64f38..b20e13a 100644 --- a/gems/dexpace-core/sig/dexpace/io/typed_reads.rbs +++ b/gems/dexpace-core/sig/dexpace/io/typed_reads.rbs @@ -25,7 +25,7 @@ module Dexpace # The view protocol: protected in lib/, so a view drives its parent and nothing else may. def dexpace_consumed: () -> Integer - def dexpace_ensure_buffered: (Integer target) -> Integer + def dexpace_fill_beyond: (Integer behind, Integer want) -> Integer def dexpace_window_copy: (Integer offset, Integer count) -> String def dexpace_register_view: (Dexpace::IO::TypedReads view) -> nil def dexpace_forget_view: (Dexpace::IO::TypedReads view) -> nil diff --git a/test/fixtures/surface/dexpace-core.txt b/test/fixtures/surface/dexpace-core.txt index 422f086..7f61423 100644 --- a/test/fixtures/surface/dexpace-core.txt +++ b/test/fixtures/surface/dexpace-core.txt @@ -134,7 +134,6 @@ Dexpace::IO::BufferedSink.wrapping Dexpace::IO::BufferedSource Dexpace::IO::BufferedSource#owns_upstream? Dexpace::IO::BufferedSource#view? -Dexpace::IO::BufferedSource.__dexpace_view Dexpace::IO::BufferedSource.of_bytes Dexpace::IO::BufferedSource.over Dexpace::IO::BufferedSource.wrapping From 0d3308be5fca7e7ea3a4815056fb20b2acc39351 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Tue, 15 Sep 2026 20:56:58 +0300 Subject: [PATCH 5/8] test: the I/O contracts' suites and fakes Ten suites mirroring the nine lib/ files plus the one change to closeable.rb, every test file opening with the IDs it exercises and nesting one class per behaviour group for the 100-line cap. The three fakes under test/support/ -- FakeChunked (an #each-shaped body that yields frozen literals and an empty chunk, and records whether its ensure ran), FakeSource (#read_into only, scriptable to the zero read no real stream produces) and FakeSink (#write only, scriptable to a short count and to raise partway) -- are required by the suites that use them. The guards run red first: P3-6's fiber-held-mutex assertion against phase 2's unsynchronised #closed? (ThreadError expected but nothing was raised, on 3.2.11, 3.4.10 and 4.0.6); the fill(count) amendment's count guards against the pre-amendment fill(1) (Expected: 4096 Actual: 1); the Tasks 5-9 ladder against the finished TypedReads suite; the transitive view-invalidation test against the plan's single-flag protocol; and the IO-38 cross-thread close, sequenced through Thread::Queues. The Dexpace::IO shadow is probed with string evals, the consumer case included, because the block form of module_eval resolves a constant in the block's own lexical scope on every interpreter in the matrix. test/gates/rbs_surface_test.rb pins EOFError, IOError and Encoding as admitted by the NFR-11 scan. --- .../test/dexpace/closeable_test.rb | 57 ++ .../dexpace/error/end_of_stream_error_test.rb | 55 ++ .../test/dexpace/error/stream_error_test.rb | 56 ++ .../test/dexpace/io/buffer_test.rb | 319 +++++++ .../test/dexpace/io/buffered_sink_test.rb | 249 +++++ .../test/dexpace/io/buffered_source_test.rb | 580 ++++++++++++ .../test/dexpace/io/tee_sink_test.rb | 310 +++++++ .../test/dexpace/io/typed_reads_test.rb | 859 ++++++++++++++++++ .../test/dexpace/io/typed_writes_test.rb | 336 +++++++ gems/dexpace-core/test/dexpace/io_test.rb | 66 ++ .../dexpace-core/test/support/fake_chunked.rb | 36 + gems/dexpace-core/test/support/fake_sink.rb | 34 + gems/dexpace-core/test/support/fake_source.rb | 30 + test/gates/rbs_surface_test.rb | 8 + 14 files changed, 2995 insertions(+) create mode 100644 gems/dexpace-core/test/dexpace/error/end_of_stream_error_test.rb create mode 100644 gems/dexpace-core/test/dexpace/error/stream_error_test.rb create mode 100644 gems/dexpace-core/test/dexpace/io/buffer_test.rb create mode 100644 gems/dexpace-core/test/dexpace/io/buffered_sink_test.rb create mode 100644 gems/dexpace-core/test/dexpace/io/buffered_source_test.rb create mode 100644 gems/dexpace-core/test/dexpace/io/tee_sink_test.rb create mode 100644 gems/dexpace-core/test/dexpace/io/typed_reads_test.rb create mode 100644 gems/dexpace-core/test/dexpace/io/typed_writes_test.rb create mode 100644 gems/dexpace-core/test/dexpace/io_test.rb create mode 100644 gems/dexpace-core/test/support/fake_chunked.rb create mode 100644 gems/dexpace-core/test/support/fake_sink.rb create mode 100644 gems/dexpace-core/test/support/fake_source.rb diff --git a/gems/dexpace-core/test/dexpace/closeable_test.rb b/gems/dexpace-core/test/dexpace/closeable_test.rb index 7b8ebd6..4c2afd7 100644 --- a/gems/dexpace-core/test/dexpace/closeable_test.rb +++ b/gems/dexpace-core/test/dexpace/closeable_test.rb @@ -144,6 +144,63 @@ def initialize assert_respond_to(spy, :owned?) end + # ---- IO-38, phase 3a's P3-6: the one change phase 3a makes to this module ------------------ + + # THE assertion that distinguishes the synchronised reader from phase 2's unsynchronised one on + # CRuby, and the only one that does: it FAILS against phase 2's version, where #closed? never + # touches the mutex. Verified on 3.2.11, 3.4.10 and 4.0.6 that a Thread::Mutex held across a + # fiber suspension raises ThreadError for a second fiber of the same thread, so holding the + # latch mutex from one fiber and reading #closed? from another proves the read acquires it. + # + # IO-38 is the requirement that forces this. Design §3.1 fixes the mechanism -- the flag is + # "written and read through a Thread::Mutex rather than relying on the GVL, so the guarantee + # survives JRuby and TruffleRuby" -- and 3a's design records ("Work Phase 3a Postpones") that no + # such interpreter is in the matrix, which is precisely why this assertion carries the weight + # the behavioural IO-38 test in buffered_source_test.rb cannot. + test "closed? acquires the close mutex" do + spy = Spy.new + latch = spy.instance_variable_get(:@dexpace_close_mutex) + + holder = ::Fiber.new { latch.synchronize { ::Fiber.yield } } + holder.resume + + assert_raises(::ThreadError) { ::Fiber.new { spy.closed? }.resume } + ensure + holder&.resume if holder&.alive? + end + + # The counterpart: with the mutex free, #closed? is an ordinary read from any fiber. + test "closed? reads normally when the mutex is free" do + spy = Spy.new + answers = [] + + ::Fiber.new { answers << spy.closed? }.resume + spy.close + ::Fiber.new { answers << spy.closed? }.resume + + assert_equal([false, true], answers) + end + + # The mutex is held across the flag flip and NOTHING else -- never across #release. A #release + # that reads #closed? would deadlock on a non-reentrant Mutex if it were. + test "the close mutex is not held across release" do + reader = Class.new do + include Dexpace::Closeable + + attr_reader :seen + + def initialize = initialize_closeable(owned: true) + + private + + def release = (@seen = closed?) + end.new + + reader.close + + assert(reader.seen) + end + private def assert_predicate_by_respond_to(object, name) diff --git a/gems/dexpace-core/test/dexpace/error/end_of_stream_error_test.rb b/gems/dexpace-core/test/dexpace/error/end_of_stream_error_test.rb new file mode 100644 index 0000000..c475f1e --- /dev/null +++ b/gems/dexpace-core/test/dexpace/error/end_of_stream_error_test.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" +require "stringio" + +# IO-11, IO-12, IO-15, IO-16. +class DexpaceEndOfStreamErrorTest < DexpaceTestCase + test "is caught by rescue Dexpace::Error through Module#===" do + caught = begin + raise Dexpace::EndOfStreamError, "eof" + rescue Dexpace::Error => error + error + end + + assert_instance_of(Dexpace::EndOfStreamError, caught) + end + + # THE ancestry assertion, and it is load-bearing rather than tidy. Verified on 3.2.11, 3.4.10 and + # 4.0.6 that IO.copy_stream -- which is literally what Net::HTTP#send_request_with_body_stream + # calls -- terminates CLEANLY on an ::EOFError SUBCLASS raised by a duck-typed #readpartial. + # Outside that family copy_stream propagates and every streaming upload phase 8 performs fails, + # and nothing else in 3a would catch it. + test "inherits ::EOFError, which is what makes IO.copy_stream terminate rather than propagate" do + assert_operator(Dexpace::EndOfStreamError, :<, ::EOFError) + end + + # The proof, not the restatement: a duck-typed source that raises this class drives + # IO.copy_stream to a clean finish with the payload intact. + test "IO.copy_stream terminates on it and keeps the payload" do + source = Class.new do + def initialize = @remaining = 3 + + def readpartial(_maxlen, outbuf = nil) + raise Dexpace::EndOfStreamError, "done" if @remaining.zero? + + @remaining -= 1 + outbuf.nil? ? +"xy" : outbuf.replace(+"xy") + end + end.new + destination = StringIO.new(+"".b) + + ::IO.copy_stream(source, destination) + + assert_equal("xyxyxy", destination.string) + end + + # A ::StandardError, so `rescue => e` catches it; it is deliberately NOT in the IOError family, + # because IO-24 requires end of stream to stay distinct from a state/contract failure. + test "is not a Dexpace::StreamError, so EOF and a contract violation stay distinct" do + refute_operator(Dexpace::EndOfStreamError, :<, Dexpace::StreamError) + assert_operator(Dexpace::EndOfStreamError, :<, ::StandardError) + end +end diff --git a/gems/dexpace-core/test/dexpace/error/stream_error_test.rb b/gems/dexpace-core/test/dexpace/error/stream_error_test.rb new file mode 100644 index 0000000..acbe36c --- /dev/null +++ b/gems/dexpace-core/test/dexpace/error/stream_error_test.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# IO-4, IO-9, IO-17, IO-28, and BODY-13's one-helper rule. +class DexpaceStreamErrorTest < DexpaceTestCase + test "is caught by rescue Dexpace::Error through Module#===" do + caught = begin + raise Dexpace::StreamError, "boom" + rescue Dexpace::Error => error + error + end + + assert_instance_of(Dexpace::StreamError, caught) + end + + # IO-4, IO-17 and IO-42 each literally say "an I/O error", and ::IOError is Ruby's root for that + # family, so an existing `rescue IOError` site keeps matching. + test "is in Ruby's IOError family" do + assert_operator(Dexpace::StreamError, :<, ::IOError) + end + + # NOT because of XCUT-4: XCUT-4 makes a transport error report itself as always-retryable, and a + # stream-contract violation must not claim that. StreamError is a sibling of phase 8's + # TransportError inside ::IOError, never a subclass, so this stays true when phase 8 lands. + test "is not an EOF error, so a contract violation is never read as end of stream" do + refute_operator(Dexpace::StreamError, :<, ::EOFError) + end + + # BODY-13 requires the short-transfer message form of BODY-10/HTTP-39 and the zero-read form of + # BODY-25 to come from ONE helper "so the message form cannot diverge". 3b calls these. + test "short_transfer builds the one message form for a short transfer" do + error = Dexpace::StreamError.short_transfer(transferred: 3, expected: 10) + + assert_instance_of(Dexpace::StreamError, error) + assert_includes(error.message, "3") + assert_includes(error.message, "10") + end + + test "zero_read builds the one message form for IO-17's source-contract violation" do + error = Dexpace::StreamError.zero_read(requested: 4096) + + assert_instance_of(Dexpace::StreamError, error) + assert_includes(error.message, "4096") + assert_includes(error.message, "IO-17") + end + + # The two helpers build and return; they do not raise. A caller writes `raise` in front of them, + # which is what keeps the raise site -- and its backtrace -- at the violation. + test "the helpers return an unraised error" do + assert_nil(Dexpace::StreamError.short_transfer(transferred: 0, expected: 1).backtrace) + assert_nil(Dexpace::StreamError.zero_read(requested: 1).backtrace) + end +end diff --git a/gems/dexpace-core/test/dexpace/io/buffer_test.rb b/gems/dexpace-core/test/dexpace/io/buffer_test.rb new file mode 100644 index 0000000..a531d7e --- /dev/null +++ b/gems/dexpace-core/test/dexpace/io/buffer_test.rb @@ -0,0 +1,319 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# IO-7, IO-8, IO-9, IO-10, IO-18, IO-41, IO-42. +# +# One class per behaviour group, because Metrics/ClassLength caps a class at 100 lines: the FIFO +# here, then Snapshot, CopyTo and Close below. +class DexpaceBufferTest < DexpaceTestCase + # One shared factory for every class below. + module Buffers + def buffer(*strings) + Dexpace::IO::Buffer.new.tap { |subject| subject.write(*strings) unless strings.empty? } + end + end + include Buffers + + # ---- IO-7: the FIFO ------------------------------------------------------------------------ + + # IO-7: "a FIFO byte queue that is simultaneously a source and a sink: bytes written through + # its sink surface MUST be read back through its source surface in the exact order written, and + # its size MUST reflect the number of bytes currently held." + test "bytes written through the sink surface read back in order through the source surface" do + subject = buffer("ab", "cd", "ef") + + assert_equal(6, subject.bytesize) + assert_equal("abcdef", subject.read) + assert_equal(0, subject.bytesize) + end + + test "bytesize reflects what is currently held, not what was ever written" do + subject = buffer("abcdef") + subject.read_exactly(2) + + assert_equal(4, subject.bytesize) + end + + test "it is simultaneously a source and a sink" do + subject = buffer + + assert_kind_of(Dexpace::IO::TypedReads, subject) + assert_kind_of(Dexpace::IO::TypedWrites, subject) + assert_kind_of(Dexpace::Closeable, subject) + refute_kind_of(Dexpace::IO::BufferedSource, subject) + end + + # The mandatory property test (styleguide 11.7): N random BINARY chunks written through the + # sink surface and read back through the source surface, asserting FIFO order and byte equality. + test "random chunks round trip through the sink and source surfaces in order" do + sample(count: 64) do |rng| + chunks = Array.new(rng.rand(1..8)) { rng.bytes(rng.rand(0..96)) } + subject = buffer + chunks.each { |chunk| subject.write(chunk) } + + assert_equal(chunks.join.b.bytesize, subject.bytesize) + assert_equal(chunks.join.b, subject.read) + end + end + + test "interleaved writes and reads keep FIFO order" do + sample(count: 48) do |rng| + subject = buffer + written = +"".b + read = +"".b + rng.rand(1..10).times do + if rng.rand(2).zero? + chunk = rng.bytes(rng.rand(0..32)) + written << chunk + subject.write(chunk) + else + taken = subject.read(rng.rand(0..16)) + read << taken unless taken.nil? + end + end + read << subject.read + + assert_equal(written, read) + end + end + + # IO-18's explicit MAY for a pure in-memory buffer. + test "emit and flush are no-ops that return self" do + subject = buffer("abc") + + assert_same(subject, subject.emit) + assert_same(subject, subject.flush) + assert_equal("abc", subject.read) + end + + # ---- IO-8, IO-9: snapshot ------------------------------------------------------------------ + class Snapshot < DexpaceTestCase + include Buffers + + # IO-8: "a fresh, independent byte-array copy of the buffer's current contents without + # consuming or otherwise mutating the buffer, such that later mutations of the buffer do not + # affect a previously returned snapshot and vice versa." + test "snapshot neither consumes nor mutates the buffer" do + subject = buffer("abc") + + assert_equal("abc", subject.snapshot) + assert_equal(3, subject.bytesize) + assert_equal("abc", subject.read) + end + + test "a later write does not reach a snapshot already handed out" do + subject = buffer("abc") + taken = subject.snapshot + subject.write("def") + + assert_equal("abc", taken) + end + + # P3-10: the snapshot comes back UNFROZEN, because IO-8's "and vice versa" presumes the + # caller may mutate it. The styleguide's freeze-every-returned-collection rule names + # collections, hashes and structs, and a String is none of the three. + test "a snapshot is unfrozen and mutating it does not reach the buffer" do + subject = buffer("abc") + taken = subject.snapshot + + refute_predicate(taken, :frozen?) + taken << "zzz" + + assert_equal("abc", subject.snapshot) + end + + test "a snapshot is BINARY even for non-ASCII content" do + assert_equal(::Encoding::BINARY, buffer("héllo").snapshot.encoding) + end + + test "a snapshot of an empty buffer is an empty BINARY String" do + taken = buffer.snapshot + + assert_equal("", taken) + assert_equal(::Encoding::BINARY, taken.encoding) + end + + test "a snapshot starts at the cursor, not at the first byte ever written" do + subject = buffer("abcdef") + subject.read_exactly(2) + + assert_equal("cdef", subject.snapshot) + end + + # The only test in 3a that allocates anything large: a Buffer built just over the ceiling, + # so #snapshot's size guard is exercised for real. It costs one ~64 MiB String allocation, + # once per run per matrix row, and it runs on EVERY row -- skipping it somewhere would leave + # the ceiling's only size-based assertion untested exactly where it might first break. + test "snapshot refuses a buffer over the ceiling and names the streaming alternatives" do + subject = buffer + over = Dexpace::IO::MAX_MATERIALIZED_BYTES + 1 + subject.write("\0".b * over) + + assert_equal(over, subject.bytesize) + error = assert_raises(Dexpace::StreamError) { subject.snapshot } + + assert_includes(error.message, "MAX_MATERIALIZED_BYTES") + assert_includes(error.message, "#each") + assert_includes(error.message, "#copy_to") + end + + test "snapshot of a buffer at the ceiling is not refused for its size" do + subject = buffer + + # No allocation: the guard reads #bytesize, so an empty buffer proves the comparison is <=. + assert_equal("", subject.snapshot) + end + end + + # ---- IO-10 --------------------------------------------------------------------------------- + class CopyTo < DexpaceTestCase + include Buffers + + test "clear discards every byte" do + subject = buffer("abcdef") + subject.clear + + assert_equal(0, subject.bytesize) + assert_equal("", subject.read) + end + + test "a cleared buffer accepts new writes and reads them back" do + subject = buffer("abc") + subject.clear + subject.write("xyz") + + assert_equal("xyz", subject.read) + end + + # IO-10: "copyTo(out, offset, byteCount) MUST copy the specified window into another buffer + # WITHOUT consuming or mutating the source buffer, defaulting to 'from offset through end'". + test "copy_to copies a window without consuming or mutating the source" do + source = buffer("abcdef") + target = buffer + + source.copy_to(target, offset: 1, count: 3) + + assert_equal("bcd", target.read) + assert_equal("abcdef", source.read) + end + + test "copy_to defaults to from offset through end, and to the whole buffer" do + source = buffer("abcdef") + target = buffer + + source.copy_to(target, offset: 2) + source.copy_to(target) + + assert_equal("cdefabcdef", target.read) + end + + test "copy_to measures its offset from the cursor and appends to the target" do + source = buffer("ab", "cdef") + source.read_exactly(1) + target = buffer("x") + + source.copy_to(target, offset: 1, count: 3) + + assert_equal("xcde", target.read) + end + + # IO-10: "rejecting out-of-range windows (negative offset/byteCount or offset+byteCount>size)". + # P3-4: copy_to is NOT a ceiling case at all -- it materialises nothing contiguous of its own, + # so an out-of-range window here is IO-10's argument error and never IO-9's StreamError. + test "copy_to rejects an out-of-range window and leaves both buffers untouched" do + source = buffer("abc") + target = buffer + + assert_raises(Dexpace::InvalidArgumentError) { source.copy_to(target, offset: 0, count: 9) } + assert_raises(Dexpace::InvalidArgumentError) { source.copy_to(target, offset: 9, count: 1) } + assert_raises(Dexpace::InvalidArgumentError) { source.copy_to(target, offset: -1, count: 1) } + assert_raises(Dexpace::InvalidArgumentError) { source.copy_to(target, offset: 0, count: -1) } + assert_raises(Dexpace::InvalidArgumentError) { source.copy_to(target, offset: 4) } + assert_raises(Dexpace::InvalidArgumentError) { source.copy_to("nope") } + assert_equal(0, target.bytesize) + assert_equal("abc", source.read) + end + + test "copy_to of an over-ceiling window is an argument error, never a ceiling error" do + source = buffer("abc") + + error = assert_raises(Dexpace::InvalidArgumentError) do + source.copy_to(buffer, offset: 0, count: Dexpace::IO::MAX_MATERIALIZED_BYTES + 1) + end + + refute_kind_of(Dexpace::StreamError, error) + end + + test "copy_to with an empty window at the end copies nothing and is not an error" do + source = buffer("abc") + target = buffer + + assert_nil(source.copy_to(target, offset: 3)) + assert_equal(0, target.bytesize) + end + end + + # ---- IO-41, IO-42 -------------------------------------------------------------------------- + class Close < DexpaceTestCase + include Buffers + + test "close is idempotent" do + subject = buffer("abc") + + subject.close + subject.close + + assert_predicate(subject, :closed?) + end + + # IO-42: "A purely in-memory buffer is exempt on its OWN read/write surface (an in-memory + # close frees nothing and may remain readable so snapshot-after-close logging still works)". + # This is one of the two tests that fail in OPPOSITE directions; buffered_source_test.rb has + # the other, and a single "it raises after close" test would pass over either error. + test "a closed buffer stays readable and writable on its own surface" do + subject = buffer("abc") + subject.close + + assert_equal("abc", subject.snapshot) + assert_equal(3, subject.bytesize) + assert_equal(3, subject.write("def")) + assert_same(subject, subject.emit) + assert_same(subject, subject.flush) + assert_equal("abcdef", subject.read) + end + + # IO-42's other half, which the exemption does NOT cover: "its close() MUST still invalidate + # every slice derived from it (see IO-22/IO-38)." + test "closing a buffer still invalidates every view derived from it" do + subject = buffer("abcdef") + view = subject.slice(offset: 0, count: 3) + peeked = subject.peek + + subject.close + + assert_raises(Dexpace::ClosedError) { view.read } + assert_raises(Dexpace::ClosedError) { peeked.read } + end + + test "a view over a buffer reads without consuming it" do + subject = buffer("abcdef") + + assert_equal("abc", subject.slice(offset: 0, count: 3).read) + assert_equal("abcdef", subject.peek.read) + assert_equal("abcdef", subject.read) + end + + # A view over a buffer is length-bounded by the buffer's own contents at the time it is + # driven, and its read never pulls from anywhere else (a buffer has no upstream). + test "a peek over a buffer sees bytes written after it was taken" do + subject = buffer("ab") + peeked = subject.peek + subject.write("cd") + + assert_equal("abcd", peeked.read) + end + end +end diff --git a/gems/dexpace-core/test/dexpace/io/buffered_sink_test.rb b/gems/dexpace-core/test/dexpace/io/buffered_sink_test.rb new file mode 100644 index 0000000..3fe6b57 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/io/buffered_sink_test.rb @@ -0,0 +1,249 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../support/fake_sink" +require "dexpace" +require "stringio" + +# IO-4, IO-5, IO-6, IO-16..IO-18, IO-41, IO-42. +# +# One class per behaviour group, because Metrics/ClassLength caps a class at 100 lines: ownership +# and the bridge here, then Staging and Close below. +class DexpaceBufferedSinkTest < DexpaceTestCase + # ---- IO-6: ownership ----------------------------------------------------------------------- + + test "wrapping takes ownership: closing the sink closes the wrapped stream" do + stream = StringIO.new(+"".b) + sink = Dexpace::IO::BufferedSink.wrapping(stream) + sink.write("ab") + sink.close + + assert_predicate(stream, :closed?) + end + + test "wrapping with a block closes on any exit path and returns the block's value" do + stream = StringIO.new(+"".b) + + result = Dexpace::IO::BufferedSink.wrapping(stream) { |sink| sink.write("abc") } + + assert_equal(3, result) + assert_predicate(stream, :closed?) + end + + test "wrapping with a block closes when the block raises" do + stream = StringIO.new(+"".b) + + assert_raises(RuntimeError) { Dexpace::IO::BufferedSink.wrapping(stream) { raise "boom" } } + assert_predicate(stream, :closed?) + end + + # R1's mitigation on the write side: respond_to?, never is_a?. + test "wrapping accepts anything that responds to write" do + assert_equal(2, Dexpace::IO::BufferedSink.wrapping(FakeSink.new).write("ab")) + end + + test "wrapping rejects an object with no write" do + error = assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::IO::BufferedSink.wrapping(Object.new) + end + + assert_includes(error.message, "#write") + end + + # ---- IO-16: the writable bridge ------------------------------------------------------------ + + # IO-16's symmetric half: IO.copy_stream needs a destination whose #write returns the byte count. + test "IO.copy_stream writes into a wrapped sink" do + stream = StringIO.new(+"".b) + sink = Dexpace::IO::BufferedSink.wrapping(stream) + + ::IO.copy_stream(StringIO.new("héllo wörld" * 40), sink) + + assert_equal(("héllo wörld" * 40).b, stream.string) + end + + test "a non-ASCII write does not retag the underlying stream's buffer to UTF-8" do + stream = StringIO.new(+"".b) + Dexpace::IO::BufferedSink.wrapping(stream).write("é") + + assert_equal(::Encoding::BINARY, stream.string.encoding) + end + + test "IO.copy_stream into a wrapped pipe end delivers every byte" do + reader, writer = ::IO.pipe + sink = Dexpace::IO::BufferedSink.wrapping(writer) + + ::IO.copy_stream(StringIO.new("héllo".b), sink) + sink.close + + assert_predicate(writer, :closed?) + assert_equal("héllo".b, reader.read.b) + ensure + reader&.close + end + + # ---- IO-4, IO-17 over a real destination --------------------------------------------------- + + test "write_from moves bytes out of a Buffer and into the underlying stream" do + stream = StringIO.new(+"".b) + buffer = Dexpace::IO::Buffer.new + buffer.write("abcdef") + + Dexpace::IO::BufferedSink.wrapping(stream).write_from(buffer, count: 4) + + assert_equal("abcd", stream.string) + assert_equal("ef", buffer.read) + end + + test "write_all pumps a BufferedSource into the underlying stream and returns the total" do + stream = StringIO.new(+"".b) + source = Dexpace::IO::BufferedSource.of_bytes("héllo wörld") + + total = Dexpace::IO::BufferedSink.wrapping(stream).write_all(source) + + assert_equal("héllo wörld".bytesize, total) + assert_equal("héllo wörld".b, stream.string) + end + + # ---- the staging buffer, the short-write rule and IO-5/IO-18 ------------------------------- + class Staging < DexpaceTestCase + # IO-17's zero-read rule applied symmetrically on the write side: an underlying #write that + # accepted fewer bytes than it was handed is a sink-contract violation. No real Ruby stream + # produces this on a blocking write, which is why FakeSink is scriptable to. + test "an underlying write that accepts fewer bytes than it was handed is an I/O error" do + underlying = FakeSink.new(1) + sink = Dexpace::IO::BufferedSink.wrapping(underlying) + + error = assert_raises(Dexpace::StreamError) { sink.write("abcd") } + + assert_includes(error.message, "1") + assert_includes(error.message, "4") + end + + test "a failed underlying write leaves no staged bytes to prepend to the next one" do + underlying = FakeSink.new(::RuntimeError.new("boom")) + sink = Dexpace::IO::BufferedSink.wrapping(underlying) + + assert_raises(::RuntimeError) { sink.write("abc") } + sink.write("def") + + assert_equal("def", underlying.written) + end + + test "an underlying write that returns no count is taken at its word" do + underlying = FakeSink.new(nil) + underlying.define_singleton_method(:write) do |*strings| + @writes << strings.join.b + :ok + end + sink = Dexpace::IO::BufferedSink.wrapping(underlying) + + assert_equal(3, sink.write("abc")) + assert_equal("abc", underlying.written) + end + + # IO-18: emit pushes one level "without forcing a system-level flush", flush forces bytes all + # the way out. The distinction is real here rather than notional: emit never calls the + # underlying #flush and flush always does. + test "emit does not call the underlying flush and flush does" do + flushes = 0 + underlying = FakeSink.new + underlying.define_singleton_method(:flush) { flushes += 1 } + sink = Dexpace::IO::BufferedSink.wrapping(underlying) + + sink.write("ab") + sink.emit + + assert_equal(0, flushes) + + sink.flush + + assert_equal(1, flushes) + end + + test "emit and flush return self, and flush tolerates an underlying stream with no flush" do + sink = Dexpace::IO::BufferedSink.wrapping(FakeSink.new) + + assert_same(sink, sink.emit) + assert_same(sink, sink.flush) + end + + # The staging buffer is empty between calls: every delivered String pushes through at once, + # so the underlying stream sees one call per String, in order, and never a coalesced tail. + test "each delivered String reaches the underlying stream as its own call, in order" do + underlying = FakeSink.new + sink = Dexpace::IO::BufferedSink.wrapping(underlying) + sink.write("ab", "cd") + sink.write("ef") + + assert_equal(%w[ab cd ef], underlying.writes) + end + end + + # ---- IO-41, IO-42 -------------------------------------------------------------------------- + class Close < DexpaceTestCase + test "close is idempotent and closes the underlying stream at most once" do + closes = 0 + underlying = FakeSink.new + underlying.define_singleton_method(:close) { closes += 1 } + sink = Dexpace::IO::BufferedSink.wrapping(underlying) + + sink.close + sink.close + + assert_equal(1, closes) + end + + # IO-42: "MUST reject read/write/flush/emit attempts made after close() with an I/O error". + test "a stream-backed sink rejects every write form after close" do + sink = Dexpace::IO::BufferedSink.wrapping(FakeSink.new) + sink.close + + assert_raises(Dexpace::ClosedError) { sink.write("ab") } + assert_raises(Dexpace::ClosedError) { sink.write_utf8("ab") } + assert_raises(Dexpace::ClosedError) { sink.write_string("ab", encoding: "UTF-8") } + one_byte = Dexpace::IO::BufferedSource.of_bytes("a") + assert_raises(Dexpace::ClosedError) { sink.write_all(one_byte) } + assert_raises(Dexpace::ClosedError) { sink.write_from(Dexpace::IO::Buffer.new, count: 0) } + assert_raises(Dexpace::ClosedError) { sink.emit } + assert_raises(Dexpace::ClosedError) { sink.flush } + end + + test "the post-close failure is a state error and never an end-of-stream error" do + sink = Dexpace::IO::BufferedSink.wrapping(FakeSink.new) + sink.close + + error = assert_raises(Dexpace::ClosedError) { sink.write("ab") } + + refute_kind_of(::EOFError, error) + refute_kind_of(Dexpace::StreamError, error) + end + + test "close flushes what is staged before closing the underlying stream" do + stream = StringIO.new(+"".b) + sink = Dexpace::IO::BufferedSink.wrapping(stream) + sink.write("abc") + sink.close + + assert_equal("abc", stream.string) + end + + test "closing a sink over an underlying stream with no close is fine" do + sink = Dexpace::IO::BufferedSink.wrapping(FakeSink.new) + + assert_nil(sink.close) + assert_predicate(sink, :closed?) + end + + # Phase 2's rule: a fake that has drifted from the seam is a suite that proves nothing. + # FakeSink implements #write and nothing else, so it IS Dexpace::IO::_Sink. + test "FakeSink is exactly the _Sink shape" do + fake = FakeSink.new + + assert_respond_to(fake, :write) + assert_equal(4, fake.write("abcd")) + assert_equal("abcd", fake.written) + end + end +end diff --git a/gems/dexpace-core/test/dexpace/io/buffered_source_test.rb b/gems/dexpace-core/test/dexpace/io/buffered_source_test.rb new file mode 100644 index 0000000..4cb3fc4 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/io/buffered_source_test.rb @@ -0,0 +1,580 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../support/fake_chunked" +require "dexpace" +require "stringio" + +# IO-6, IO-11..IO-24, IO-37, IO-38, IO-41, IO-42, and design §10.2. +# +# StringIO and IO.pipe stand in for a real stream and are not stubs: they give genuine EOF, genuine +# blocking and the genuine IOError a closed handle raises. IO.pipe in particular is what makes the +# IO-38 test real rather than simulated -- a reader really does block. +# +# One class per behaviour group, because Metrics/ClassLength caps a class at 100 lines: ownership +# here, then Bridge, Over, Close, Threads and Counts below. +class DexpaceBufferedSourceTest < DexpaceTestCase + # A pipe whose two ends are closed however the block leaves. + module Pipes + def pipe + reader, writer = ::IO.pipe + yield reader, writer + ensure + reader.close unless reader.nil? || reader.closed? + writer.close unless writer.nil? || writer.closed? + end + end + include Pipes + + # ---- IO-6: ownership ----------------------------------------------------------------------- + + # IO-6: "the returned wrapper MUST take ownership of that stream: closing the wrapper closes the + # underlying stream." There is deliberately no borrowing variant (P3-12). + test "wrapping takes ownership: closing the source closes the wrapped stream" do + stream = StringIO.new("abc") + source = Dexpace::IO::BufferedSource.wrapping(stream) + + assert_predicate(source, :owns_upstream?) + refute_predicate(source, :view?) + source.close + + assert_predicate(stream, :closed?) + end + + test "wrapping with a block closes on a normal exit and returns the block's value" do + stream = StringIO.new("abc") + + result = Dexpace::IO::BufferedSource.wrapping(stream, &:read) + + assert_equal("abc", result) + assert_predicate(stream, :closed?) + end + + test "wrapping with a block closes on an exception too" do + stream = StringIO.new("abc") + + assert_raises(RuntimeError) do + Dexpace::IO::BufferedSource.wrapping(stream) { raise "boom" } + end + assert_predicate(stream, :closed?) + end + + # IO-6's last sentence: "Wrapping a plain byte array owns no external resource." + test "of_bytes owns nothing and takes an independent copy of the input" do + original = +"abc" + source = Dexpace::IO::BufferedSource.of_bytes(original) + original << "def" + + refute_predicate(source, :owns_upstream?) + assert_equal("abc", source.read) + end + + test "of_bytes result is not affected by, and does not affect, the caller's String" do + original = +"abc" + source = Dexpace::IO::BufferedSource.of_bytes(original) + + assert_equal("abc", source.read) + assert_equal("abc", original) + assert_raises(Dexpace::InvalidArgumentError) { Dexpace::IO::BufferedSource.of_bytes(:sym) } + end + + # R3's one stated exception, and it is design §3.1's rather than this phase's: .over takes no + # ownership, because its callers are always downstream of something that already owns the + # response. This is what removes the one 3a->3b edge that would have run backwards. + test "over owns nothing and never closes what it wrapped" do + closed = false + body = Object.new + body.define_singleton_method(:each) { |&block| block.call("ab") } + body.define_singleton_method(:close) { closed = true } + + source = Dexpace::IO::BufferedSource.over(body) + source.read + source.close + + refute_predicate(source, :owns_upstream?) + refute(closed) + assert_raises(Dexpace::InvalidArgumentError) { Dexpace::IO::BufferedSource.over(Object.new) } + end + + # R1's real mitigation, asserted rather than argued: core never writes is_a?(IO), so every one + # of these works. A nominal test would be silently false inside `module Dexpace`. + test "wrapping accepts a real IO, a StringIO and a bare readpartial-shaped object" do + duck = Class.new do + def initialize = @sent = false + + def readpartial(_maxlen, _outbuf = nil) + raise ::EOFError if @sent + + @sent = true + +"duck" + end + end.new + + pipe do |reader, writer| + writer.write("pipe") + writer.close + + assert_equal("pipe", Dexpace::IO::BufferedSource.wrapping(reader).read) + end + assert_equal("sio", Dexpace::IO::BufferedSource.wrapping(StringIO.new("sio")).read) + assert_equal("duck", Dexpace::IO::BufferedSource.wrapping(duck).read) + end + + test "wrapping rejects an object that reads no way at all" do + error = assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::IO::BufferedSource.wrapping(Object.new) + end + + assert_includes(error.message, "#readpartial") + end + + # P3-11: ownership must not be settable through an unnamed argument, or a caller builds the + # wrapper IO-6 forbids. + test "new is private on the two wrapping classes and public on the two that own nothing" do + assert_raises(::NoMethodError) { Dexpace::IO::BufferedSource.new } + assert_raises(::NoMethodError) { Dexpace::IO::BufferedSink.new } + assert_instance_of(Dexpace::IO::Buffer, Dexpace::IO::Buffer.new) + assert_instance_of(Dexpace::IO::TeeSink, Dexpace::IO::TeeSink.new(primary: StringIO.new)) + end + + # ---- IO-16 and R4: the bridge, proved rather than restated --------------------------------- + class Bridge < DexpaceTestCase + include Pipes + + # THE bridge proof. This is the exact mechanism Net::HTTP#body_stream= uses: + # Net::HTTP#send_request_with_body_stream is IO.copy_stream(f, sock). Not a mock of it. + test "IO.copy_stream drives a wrapped source and the bytes arrive intact" do + destination = StringIO.new(+"".b) + + pipe do |reader, writer| + writer.write("héllo wörld" * 40) + writer.close + ::IO.copy_stream(Dexpace::IO::BufferedSource.wrapping(reader), destination) + end + + assert_equal(("héllo wörld" * 40).b, destination.string) + end + + # IO-6's second sentence -- "closing the bridge closes the owning source/sink" -- is trivially + # true when the bridge IS the source, which is exactly why it gets an assertion: "trivially + # true" is what a later refactor splitting the bridge into its own object would silently break. + test "closing the source after IO.copy_stream closes the wrapped stream" do + pipe do |reader, writer| + writer.write("abc") + writer.close + source = Dexpace::IO::BufferedSource.wrapping(reader) + ::IO.copy_stream(source, StringIO.new(+"".b)) + source.close + + assert_predicate(reader, :closed?) + end + end + + # A source with #read and no #readpartial is driven through read(len, buf) instead, which is + # IO.copy_stream's own fallback; the source below has both, so both are the same object. + test "IO.copy_stream drives a source over a body with no readpartial of its own" do + destination = StringIO.new(+"".b) + source = Dexpace::IO::BufferedSource.over(FakeChunked.new("ab", "", "cd")) + + ::IO.copy_stream(source, destination) + + assert_equal("abcd", destination.string) + end + + # Verified fact 11: StringIO#read(n, buf)'s destination encoding changed at exactly Ruby 3.4 + # (ASCII-8BIT on 3.2.11, UTF-8 from 3.4.10) while IO#read preserves the destination's tag on + # all three. This is a floor-straddler like URI::DEFAULT_PARSER, so it is pinned rather than + # assumed: 3a never infers a tag from a destination or a source. + test "a StringIO-backed source returns BINARY on every interpreter in the matrix" do + source = Dexpace::IO::BufferedSource.wrapping(StringIO.new("héllo")) + + drained = source.read + + assert_equal(::Encoding::BINARY, drained.encoding) + assert_equal("héllo".b, drained) + end + + test "a pipe-backed source returns BINARY too" do + pipe do |reader, writer| + writer.write("héllo") + writer.close + + assert_equal(::Encoding::BINARY, + Dexpace::IO::BufferedSource.wrapping(reader).read.encoding,) + end + end + + # A wrapped stream with only #read (no #readpartial) is filled through read(n), whose nil + # is the second EOF sentinel #fill normalises. + test "a read-only upstream's nil at EOF is normalised to the primitive's -1" do + upstream = Object.new + chunks = [+"ab", nil] + upstream.define_singleton_method(:read) { |_n| chunks.shift } + source = Dexpace::IO::BufferedSource.wrapping(upstream) + + assert_equal(2, source.read_into(+"".b, count: 8)) + assert_equal(-1, source.read_into(+"".b, count: 8)) + assert_equal(-1, source.read_into(+"".b, count: 8)) + end + end + + # ---- §10.2 and .over ----------------------------------------------------------------------- + class Over < DexpaceTestCase + # Verified fact 3: force_encoding on a frozen String raises FrozenError even when the target + # encoding is already the string's own, and a Rack-style body's #each yields frozen literals. + # String#b is the ingress idiom; force_encoding appears nowhere on this path. + test "over survives a body that yields frozen literals, and stores them BINARY" do + source = Dexpace::IO::BufferedSource.over(FakeChunked.frozen_utf8) + + drained = source.read + + assert_equal(::Encoding::BINARY, drained.encoding) + assert_equal("héllo wörld".b, drained) + end + + # Verified fact 4: appending a NON-ASCII UTF-8 String to a BINARY one silently retags the + # result to UTF-8 while an ASCII-only one does not -- so an ASCII-only fixture would pass + # under exactly the bug. Every encoding test in 3a therefore uses non-ASCII bytes. + test "a non-ASCII chunk does not retag the buffer to UTF-8" do + source = Dexpace::IO::BufferedSource.over(FakeChunked.new("é")) + + assert_equal(::Encoding::BINARY, source.read.encoding) + end + + # A frozen BINARY chunk is kept without a copy; anything else is copied exactly once. + test "over keeps an already-frozen BINARY chunk by identity and copies every other" do + frozen_binary = "ab".b.freeze + source = Dexpace::IO::BufferedSource.over(FakeChunked.new(frozen_binary, +"cd")) + source.read_exactly(0) + source.send(:ensure_buffered, 4) + stored = source.instance_variable_get(:@dexpace_chunks) + + assert_same(frozen_binary, stored[0]) + assert_predicate(stored[1], :frozen?) + assert_equal(::Encoding::BINARY, stored[1].encoding) + end + + # .over preserves the wrapped body's own chunking, which is what BODY-17's byte-exact + # mirroring needs. This is the plan's answer to the open question about #each's granularity. + test "over preserves the wrapped body's chunk boundaries through each" do + source = Dexpace::IO::BufferedSource.over(FakeChunked.new("ab", "cde", "f")) + + assert_equal(%w[ab cde f], source.each.to_a) + end + + # IO-1's "at least 1 when byteCount>0 and the source is NOT exhausted". An empty chunk means + # "no bytes this time", never "no bytes ever": Rack permits #each to yield "", and collapsing + # the two truncates the body there -- silently, and with a well-formed short read that no + # length check downstream would question. StopIteration is the only end-of-stream signal here. + test "an empty chunk between two non-empty ones is not end of stream" do + source = Dexpace::IO::BufferedSource.over(FakeChunked.new("ab", "", "cd")) + + assert_equal("abcd", source.read) + end + + test "a leading empty chunk does not make the body look exhausted" do + assert_equal("abc", Dexpace::IO::BufferedSource.over(FakeChunked.new("", "abc")).read) + end + + test "an empty chunk truncates neither each nor read_exactly, and eof? stays false" do + body = -> { FakeChunked.new("ab", "", "cd") } + + assert_equal(%w[ab cd], Dexpace::IO::BufferedSource.over(body.call).each.to_a) + assert_equal("abcd", Dexpace::IO::BufferedSource.over(body.call).read_exactly(4)) + + source = Dexpace::IO::BufferedSource.over(body.call) + source.read_exactly(2) + + refute_predicate(source, :eof?) + end + + test "over pulls on demand, so no read-ahead accumulates" do + body = FakeChunked.new("ab", "cd", "ef") + source = Dexpace::IO::BufferedSource.over(body) + + source.read_exactly(1) + + assert_equal(1, body.yielded) + end + + # §7.1's residue, asserted rather than papered over: a source closed before exhaustion + # abandons the enumerator, and an abandoned Enumerator never runs its ensure (verified on + # 3.2.11, 3.4.10 and 4.0.6; #rewind does not run it either). Nothing core owns leaks, + # because .over owns nothing -- the enumerator holds the caller's body, and the caller's body + # is owned by whoever created it. .over's YARD block says so. + test "closing an unexhausted over source leaves the wrapped body's own ensure unrun" do + body = FakeChunked.new("ab", "cd", "ef") + source = Dexpace::IO::BufferedSource.over(body) + source.read_exactly(1) + source.close + + refute_predicate(body, :ensure_ran) + end + + test "draining an over source to exhaustion does run the body's ensure" do + body = FakeChunked.new("ab", "cd") + Dexpace::IO::BufferedSource.over(body).read + + assert_predicate(body, :ensure_ran) + end + + test "a BufferedSource is itself a canonical body representation" do + assert_respond_to(Dexpace::IO::BufferedSource.of_bytes("ab"), :each) + assert_equal(["ab"], Dexpace::IO::BufferedSource.over(FakeChunked.new("ab")).to_enum.to_a) + end + end + + # ---- IO-41, IO-42: close ------------------------------------------------------------------ + class Close < DexpaceTestCase + # IO-41: "a second (or later) close() MUST NOT throw, and the underlying resource MUST be + # closed at most once." + test "close is idempotent and closes the underlying resource at most once" do + closes = 0 + stream = Object.new + stream.define_singleton_method(:read) { |_n| nil } + stream.define_singleton_method(:close) { closes += 1 } + + source = Dexpace::IO::BufferedSource.wrapping(stream) + source.close + source.close + source.close + + assert_equal(1, closes) + end + + # IO-42: "A buffered source/sink that wraps an external stream MUST reject read/write/flush/ + # emit attempts made after close() with an I/O error." P3-3: that error is phase 2's + # Dexpace::ClosedError, whose first raise site is here. + test "a stream-backed source rejects every read form after close" do + source = Dexpace::IO::BufferedSource.wrapping(StringIO.new("abcdef")) + source.close + + assert_raises(Dexpace::ClosedError) { source.read } + assert_raises(Dexpace::ClosedError) { source.read_exactly(1) } + assert_raises(Dexpace::ClosedError) { source.readpartial(1) } + assert_raises(Dexpace::ClosedError) { source.read_into(+"".b, count: 1) } + assert_raises(Dexpace::ClosedError) { source.read_into(+"".b, count: 0) } + assert_raises(Dexpace::ClosedError) { source.eof? } + assert_raises(Dexpace::ClosedError) { source.peek } + end + + # The two directions of IO-42's asymmetry get two tests that fail in OPPOSITE ways, because + # a single "it raises after close" test would pass over either error. This is the + # stream-backed half; buffer_test.rb carries the in-memory half. + test "the post-close failure is a state error and never an end-of-stream error" do + source = Dexpace::IO::BufferedSource.of_bytes("abc") + source.close + + error = assert_raises(Dexpace::ClosedError) { source.read } + + refute_kind_of(::EOFError, error) + refute_kind_of(Dexpace::StreamError, error) + end + + test "closed? stays public with the same arity after P3-6" do + source = Dexpace::IO::BufferedSource.of_bytes("abc") + + assert_respond_to(source, :closed?) + assert_equal(0, source.method(:closed?).arity) + refute_predicate(source, :closed?) + source.close + + assert_predicate(source, :closed?) + end + + test "close drops the buffer, so a closed source retains nothing" do + source = Dexpace::IO::BufferedSource.of_bytes("abcdef") + source.read_exactly(1) + source.close + + assert_empty(source.instance_variable_get(:@dexpace_chunks)) + end + + # A wrapped object with no #close is a legal upstream (a caller's own #readpartial-shaped + # object); closing the source then releases only the buffer. + test "closing a source over an upstream with no close is fine" do + upstream = Object.new + upstream.define_singleton_method(:readpartial) { |_n| raise ::EOFError } + source = Dexpace::IO::BufferedSource.wrapping(upstream) + + assert_nil(source.close) + assert_predicate(source, :closed?) + end + end + + # ---- IO-37, IO-38: the two threads on one flag --------------------------------------------- + class Threads < DexpaceTestCase + include Pipes + + # IO-37: "All streaming instances ... are single-threaded contracts". The proof that no lock + # is held across a read is the LOCK'S ABSENCE, and it is testable: verified on all three + # interpreters that a Thread::Mutex held across a fiber suspension raises ThreadError for a + # second fiber of the same thread. So this test fails loudly under exactly the bug it exists + # to catch, and it is driven with Fiber.new/Fiber.yield rather than a scheduler. + test "two fibers of one thread interleave reads on one source without a ThreadError" do + gate = Class.new do + def initialize = @chunks = %w[ab cd ef gh] + + def readpartial(_maxlen, _outbuf = nil) + raise ::EOFError if @chunks.empty? + + ::Fiber.yield + +@chunks.shift + end + end.new + source = Dexpace::IO::BufferedSource.wrapping(gate) + read = [] + + first = ::Fiber.new { read << source.read_exactly(2) } + second = ::Fiber.new { read << source.read_exactly(2) } + first.resume + second.resume + first.resume + second.resume + + assert_equal(%w[ab cd], read) + end + + # IO-38: "the CLOSE state of a source/buffer MUST be observable across threads to the slices + # derived from it, so that a close on one thread reliably invalidates a slice being read on + # another (no torn or stale reads)." + # + # Sequenced through a Thread::Queue handshake, never raced: phase 2's cancellation-stamp work + # is the precedent for how a free-running race produces a flake nobody can reproduce. 3a's + # design records that this passes with or without the lock on every CRuby row -- the + # assertion that distinguishes mechanism from behaviour is the fiber-held-mutex test in + # closeable_test.rb. + test "a close on one thread invalidates a read blocked on another" do + pipe do |reader, writer| + source = Dexpace::IO::BufferedSource.wrapping(reader) + inside = ::Thread::Queue.new + outcome = ::Thread::Queue.new + + blocked = ::Thread.new do + inside.push(:reading) + begin + source.read_exactly(4) + outcome.push(:returned) + rescue ::StandardError => error + outcome.push(error) + end + end + + assert_equal(:reading, inside.pop) + ::Thread.pass until blocked.status == "sleep" || !blocked.status + source.close + writer.close + + result = outcome.pop + blocked.join + + # The blocked read fails loudly and never returns stale bytes. What it raises is Ruby's + # own ::IOError ("stream closed in another thread"), forwarded unchanged -- IO-40's "MUST + # NOT swallow OR duplicate the wrapped stream's cancellation/interrupt handling" honoured + # by doing nothing to it. Every read AFTER the close is 3a's own Dexpace::ClosedError. + refute_equal(:returned, result) + assert_instance_of(::IOError, result) + assert_raises(Dexpace::ClosedError) { source.read } + end + end + + test "a close on one thread invalidates a view being read on another" do + source = Dexpace::IO::BufferedSource.of_bytes("abcdef") + view = source.slice(offset: 0, count: 4) + ready = ::Thread::Queue.new + go = ::Thread::Queue.new + outcome = ::Thread::Queue.new + + reader = ::Thread.new do + ready.push(:ready) + go.pop + begin + outcome.push(view.read) + rescue ::StandardError => error + outcome.push(error) + end + end + + assert_equal(:ready, ready.pop) + source.close + go.push(:go) + result = outcome.pop + reader.join + + assert_instance_of(Dexpace::ClosedError, result) + end + + # IO-41 across threads: #release runs exactly once and both callers return. + test "close called from two threads releases once and both callers return" do + releases = ::Thread::Queue.new + stream = Object.new + stream.define_singleton_method(:read) { |_n| nil } + stream.define_singleton_method(:close) { releases.push(:closed) } + source = Dexpace::IO::BufferedSource.wrapping(stream) + + threads = Array.new(2) { ::Thread.new { source.close } } + threads.each(&:join) + + assert_equal(1, releases.size) + end + end + + # ---- the count the upstream is asked for, and an IO-7-shaped round trip -------------------- + class Counts < DexpaceTestCase + # Task 10's 2026-09-13 amendment. Phase 3b measured BufferedSource.wrapping(io) delivering ONE + # BYTE per #read_into for any positive count: 200 000 bytes through 200 001 readpartial(1) + # calls, ~0.21 s, on 3.2.11, 3.4.10 and 4.0.6 alike. Every IO requirement is met either way + # -- IO-1 asks only for "at least 1" -- so no gate sees it and only this assertion does. + test "wrapping passes the caller's own count through to the upstream" do + asked = [] + upstream = Object.new + upstream.define_singleton_method(:readpartial) do |want| + asked << want + raise ::EOFError if asked.size > 1 + + "x" * want + end + + Dexpace::IO::BufferedSource.wrapping(upstream) do |source| + assert_equal(4096, source.read_into(+"".b, count: 4096)) + end + + assert_equal([4096], asked) + end + + # #each and #drain_all have no caller count, so they carry READ_SEGMENT_BYTES instead of a 1. + # Two asks: one that yields the chunk and one that finds the end. + test "each and a count-less read ask the upstream for a segment, not a byte" do + asked = [] + upstream = Object.new + upstream.define_singleton_method(:readpartial) do |want| + asked << want + raise ::EOFError if asked.size > 1 + + "y" * 10 + end + + Dexpace::IO::BufferedSource.wrapping(upstream) { |source| source.each { |_| nil } } + + assert_equal([64 * 1024, 64 * 1024], asked) + end + + test "random BINARY chunks read back byte-for-byte in order through a wrapped stream" do + sample(count: 32) do |rng| + chunks = Array.new(rng.rand(1..6)) { rng.bytes(rng.rand(0..64)) } + source = Dexpace::IO::BufferedSource.wrapping(StringIO.new(chunks.join.b)) + + assert_equal(chunks.join.b, source.read) + end + end + + # A view over a wrapped stream drives the parent's fill and reads BINARY, on every row. + test "a view over a StringIO-backed source reads ahead without consuming the parent" do + source = Dexpace::IO::BufferedSource.wrapping(StringIO.new("héllo wörld")) + + assert_equal("héllo".b, source.slice(offset: 0, count: 6).read) + assert_equal("héllo wörld".b, source.read) + end + end +end diff --git a/gems/dexpace-core/test/dexpace/io/tee_sink_test.rb b/gems/dexpace-core/test/dexpace/io/tee_sink_test.rb new file mode 100644 index 0000000..5d41023 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/io/tee_sink_test.rb @@ -0,0 +1,310 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../support/fake_sink" +require "dexpace" + +# IO-25, IO-26, IO-27, IO-28, IO-29, IO-40, IO-41, IO-42. +# +# One class per behaviour group, because Metrics/ClassLength caps a class at 100 lines: the +# mirror and its limit here, then Failure, Lifecycle and Vocabulary below. +class DexpaceTeeSinkTest < DexpaceTestCase + # One shared factory for every class below. + module Tees + def tee(primary = FakeSink.new, **) + Dexpace::IO::TeeSink.new(primary: primary, **) + end + end + include Tees + + # ---- IO-25 --------------------------------------------------------------------------------- + + # IO-25: "MUST mirror the bytes written through it into an in-memory tap buffer AND forward the + # full, untruncated payload to its primary sink; the bytes delivered to the primary (the wire + # body) MUST never be reduced or altered by the presence of the tap." + test "the wire body reaches the primary byte for byte and the tap mirrors it" do + primary = FakeSink.new + sink = tee(primary) + + sink.write("héllo ", "wörld") + + assert_equal("héllo wörld".b, primary.written) + assert_equal("héllo wörld".b, sink.tap_snapshot) + end + + test "the primary sees the same writes with or without a tap limit" do + unlimited = FakeSink.new + limited = FakeSink.new + tee(unlimited).write("abcdef") + tee(limited, tap_limit: 2).write("abcdef") + + assert_equal(limited.writes, unlimited.writes) + end + + test "tap_snapshot is a fresh independent copy, like Buffer#snapshot" do + sink = tee + sink.write("abc") + taken = sink.tap_snapshot + sink.write("def") + + assert_equal("abc", taken) + assert_equal("abcdef", sink.tap_snapshot) + assert_equal(::Encoding::BINARY, taken.encoding) + end + + test "tap_bytesize reports what the tap currently holds" do + sink = tee + sink.write("abcd") + + assert_equal(4, sink.tap_bytesize) + end + + # ---- IO-26 --------------------------------------------------------------------------------- + + # IO-26: "once the limit is reached, further writes MUST stop copying into the tap while still + # forwarding the FULL payload to the primary." + test "the tap stops at its limit while the full payload still reaches the primary" do + primary = FakeSink.new + sink = tee(primary, tap_limit: 4) + + sink.write("abcdef") + sink.write("ghij") + + assert_equal("abcd", sink.tap_snapshot) + assert_equal("abcdefghij", primary.written) + end + + # IO-26: "The default limit MUST be effectively unbounded". + test "the default limit is effectively unbounded" do + sink = tee + sink.write("x" * 100_000) + + assert_equal(100_000, sink.tap_bytesize) + assert_includes(Dexpace::IO::TeeSink.instance_method(:initialize).parameters, + %i[key tap_limit],) + end + + # IO-26: "a limit of 0 MUST mirror nothing while still forwarding everything." + test "a limit of zero mirrors nothing and forwards everything" do + primary = FakeSink.new + sink = tee(primary, tap_limit: 0) + + sink.write("abcdef") + + assert_equal(0, sink.tap_bytesize) + assert_equal("abcdef", primary.written) + end + + test "a mid-write limit truncates the tap exactly at the limit" do + sink = tee(FakeSink.new, tap_limit: 3) + sink.write("abcdef") + + assert_equal("abc", sink.tap_snapshot) + end + + test "a negative or non-numeric tap limit is rejected at construction" do + assert_raises(Dexpace::InvalidArgumentError) { tee(FakeSink.new, tap_limit: -1) } + assert_raises(Dexpace::InvalidArgumentError) { tee(FakeSink.new, tap_limit: "lots") } + assert_raises(Dexpace::InvalidArgumentError) { tee(FakeSink.new, tap_limit: 1.5) } + end + + test "a primary with no write is rejected at construction" do + assert_raises(Dexpace::InvalidArgumentError) { Dexpace::IO::TeeSink.new(primary: Object.new) } + end + + # ---- IO-27, IO-40: the primary's failure --------------------------------------------------- + class Failure < DexpaceTestCase + include Tees + + # IO-27: "MUST mirror the attempted bytes into the tap BEFORE forwarding them to the primary + # sink, so that if the primary write fails mid-stream the attempted bytes are still captured + # in the tap". Only a scriptable FakeSink can raise partway through. + test "a failed primary write still leaves the attempted bytes in the tap" do + primary = FakeSink.new(nil, ::RuntimeError.new("boom")) + sink = tee(primary) + + sink.write("abc") + assert_raises(::RuntimeError) { sink.write("def") } + + assert_equal("abcdef", sink.tap_snapshot) + end + + # IO-27: "its staging buffer MUST be cleared even on a failed primary write so a later write + # does not prepend stale bytes." An ensure and not a rescue, so nothing is swallowed -- which + # is also how IO-40's "MUST NOT swallow OR duplicate the wrapped stream's cancellation/ + # interrupt handling" is honoured structurally. + test "the staging buffer is cleared even on a failed primary write" do + primary = FakeSink.new(::RuntimeError.new("boom")) + sink = tee(primary) + + assert_raises(::RuntimeError) { sink.write("abc") } + sink.write("def") + + assert_equal("def", primary.written) + end + + # IO-40: the primary's own failure propagates as the identical object, once. + test "the primary's failure propagates unchanged and unwrapped" do + boom = ::IOError.new("connection reset") + sink = tee(FakeSink.new(boom)) + + error = assert_raises(::IOError) { sink.write("abc") } + + assert_same(boom, error) + end + + test "a short primary write is a sink-contract violation" do + error = assert_raises(Dexpace::StreamError) { tee(FakeSink.new(1)).write("abcd") } + + assert_includes(error.message, "4") + end + end + + # ---- IO-28, IO-29, IO-41, IO-42: the lifecycle --------------------------------------------- + class Lifecycle < DexpaceTestCase + include Tees + + # IO-28: "MUST NOT expose direct access to a backing buffer (attempting it MUST fail with a + # clear error directing callers to the typed write methods)". Defined rather than absent so + # the failure is that message and not a NoMethodError. Design §10.10 records the honest + # position: the prohibition cannot be language-enforced and 3a builds no fake proof that it can. + test "buffer is defined and raises with a message naming the typed write methods" do + sink = tee + + assert_respond_to(sink, :buffer) + error = assert_raises(Dexpace::StreamError) { sink.buffer } + + assert_includes(error.message, "IO-28") + assert_includes(error.message, "#write_all") + end + + # #clear_tap is deliberately NOT here (the plan's Task 14 decision): 3b satisfies BODY-18 by + # building a fresh tee per write, which TeeSink binding its primary at construction forces, + # so the method had no caller anywhere in core while being NFR-4-locked surface. + test "no other route to the backing store is exposed" do + sink = tee + + refute_respond_to(sink, :primary) + refute_respond_to(sink, :staged) + refute_respond_to(sink, :tap_buffer) + refute_respond_to(sink, :clear_tap) + end + + # IO-29: "A TeeSink's own flush(), close(), and emit() MUST forward to the PRIMARY sink only + # (not the tap), so lifecycle/flush semantics of the real destination are preserved and the + # in-memory tap is left intact for later snapshotting." + test "flush, emit and close forward to the primary only and leave the tap intact" do + events = [] + primary = FakeSink.new + primary.define_singleton_method(:flush) { events << :flush } + primary.define_singleton_method(:emit) { events << :emit } + primary.define_singleton_method(:close) { events << :close } + sink = tee(primary) + sink.write("abc") + + sink.emit + sink.flush + sink.close + + assert_equal(%i[emit flush close], events) + assert_equal("abc", sink.tap_snapshot) + end + + test "flush and emit return self, and tolerate a primary with neither" do + sink = tee + + assert_same(sink, sink.emit) + assert_same(sink, sink.flush) + end + + test "the tap survives close, so a snapshot can still be taken afterwards" do + sink = tee + sink.write("abc") + sink.close + + assert_equal("abc", sink.tap_snapshot) + assert_equal(3, sink.tap_bytesize) + end + + test "close is idempotent and closes the primary at most once" do + closes = 0 + primary = FakeSink.new + primary.define_singleton_method(:close) { closes += 1 } + sink = tee(primary) + + sink.close + sink.close + + assert_equal(1, closes) + end + + # IO-42: a tee wraps an external stream, so it is not exempt. + test "a closed tee rejects every write form" do + sink = tee + sink.close + + assert_raises(Dexpace::ClosedError) { sink.write("ab") } + assert_raises(Dexpace::ClosedError) { sink.write_utf8("ab") } + one_byte = Dexpace::IO::BufferedSource.of_bytes("a") + assert_raises(Dexpace::ClosedError) { sink.write_all(one_byte) } + assert_raises(Dexpace::ClosedError) { sink.write_from(Dexpace::IO::Buffer.new, count: 0) } + assert_raises(Dexpace::ClosedError) { sink.emit } + assert_raises(Dexpace::ClosedError) { sink.flush } + end + end + + # ---- the whole write vocabulary reaches both sides ------------------------------------------- + class Vocabulary < DexpaceTestCase + include Tees + + test "write_all, write_from and write_utf8 all mirror and forward" do + primary = FakeSink.new + sink = tee(primary) + buffer = Dexpace::IO::Buffer.new + buffer.write("cd") + + sink.write_utf8("é") + sink.write_from(buffer, count: 2) + sink.write_all(Dexpace::IO::BufferedSource.of_bytes("ef")) + + assert_equal("écdef".b, primary.written) + assert_equal("écdef".b, sink.tap_snapshot) + end + + test "IO.copy_stream drives a tee as a destination" do + primary = FakeSink.new + sink = tee(primary, tap_limit: 3) + + ::IO.copy_stream(Dexpace::IO::BufferedSource.of_bytes("héllo"), sink) + + assert_equal("héllo".b, primary.written) + assert_equal("héllo".b.byteslice(0, 3), sink.tap_snapshot) + end + + test "the tap and the primary agree byte for byte over random chunk sequences" do + sample(count: 48) do |rng| + primary = FakeSink.new + sink = tee(primary) + chunks = Array.new(rng.rand(1..6)) { rng.bytes(rng.rand(0..48)) } + chunks.each { |chunk| sink.write(chunk) } + + assert_equal(chunks.join.b, primary.written) + assert_equal(chunks.join.b, sink.tap_snapshot) + end + end + + test "a bounded tap holds exactly the first limit bytes over random chunk sequences" do + sample(count: 32) do |rng| + limit = rng.rand(0..40) + primary = FakeSink.new + sink = tee(primary, tap_limit: limit) + chunks = Array.new(rng.rand(1..6)) { rng.bytes(rng.rand(0..32)) } + chunks.each { |chunk| sink.write(chunk) } + + assert_equal(chunks.join.b, primary.written) + assert_equal(chunks.join.b.byteslice(0, limit), sink.tap_snapshot) + end + end + end +end diff --git a/gems/dexpace-core/test/dexpace/io/typed_reads_test.rb b/gems/dexpace-core/test/dexpace/io/typed_reads_test.rb new file mode 100644 index 0000000..bd64e84 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/io/typed_reads_test.rb @@ -0,0 +1,859 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# IO-1, IO-2, IO-3, IO-9, IO-11..IO-16, IO-19..IO-24, and §10.2's #each. +# +# Written against a trivial in-test includer that supplies #fill from a fixed chunk list, so the +# read vocabulary is exercised with no upstream at all. BufferedSource's own suite covers the same +# vocabulary over a real stream. +# +# One class per behaviour group, because Metrics/ClassLength caps a class at 100 lines: the +# primitive here, then Typed, Ceiling, Lines, Bridge, Each, Views and Includer below. +class DexpaceTypedReadsTest < DexpaceTestCase + # The Ruby shape of the reference's BufferedSource interface: supply #fill, get everything. + class Scripted + include Dexpace::IO::TypedReads + include Dexpace::Closeable + + attr_reader :fills + + def initialize(*chunks) + @pending = chunks + @fills = 0 + initialize_closeable(owned: true) + initialize_typed_reads + end + + private + + def fill(_min_bytes) + @fills += 1 + chunk = @pending.shift + return 0 if chunk.nil? + + store_append(chunk) + end + + # IO-22: an includer's #release invalidates every view derived from it. Buffer and + # BufferedSource both do exactly this. + def release + dexpace_release_views + end + end + + # One shared factory for every class below, so every source under test is shaped the same. + module Sources + def source(*chunks) + Scripted.new(*chunks) + end + end + include Sources + + # ---- IO-1, IO-2, IO-3: the primitive ------------------------------------------------------- + + # IO-1: "MUST append the bytes it reads to the TAIL of the caller-provided destination buffer + # (never overwrite existing content)". + test "read_into appends to the tail and never overwrites" do + dest = +"seed".b + + transferred = source("abc").read_into(dest, count: 3) + + assert_equal(3, transferred) + assert_equal("seedabc", dest) + end + + # IO-1's four return values, and the -1 sentinel rather than Ruby's nil: IO-17's pump reads it, + # and a foreign source implementing IO-1 literally would return it. + test "read_into returns -1 when the source is exhausted before any byte" do + assert_equal(-1, source.read_into(+"".b, count: 4)) + end + + test "read_into never returns more than count" do + assert_equal(2, source("abcdef").read_into(+"".b, count: 2)) + end + + # IO-2: "A read of byteCount==0 MUST return 0 and MUST NOT report end-of-stream (-1), even when + # the source is already exhausted." Ruby's own readers do not collapse this, but IO-2 is a + # statement about 3a's surface. + test "read_into returns 0 for a zero count on an exhausted source, never -1" do + exhausted = source + exhausted.read_into(+"".b, count: 1) + + assert_equal(0, exhausted.read_into(+"".b, count: 0)) + end + + test "read_into with a zero count touches neither the buffer nor the upstream" do + subject = source("abc") + + assert_equal(0, subject.read_into(+"".b, count: 0)) + assert_equal(0, subject.fills) + end + + test "read_into fills at most once before serving, and serves from the buffer after that" do + subject = source("ab", "cd") + + assert_equal(2, subject.read_into(+"".b, count: 4)) + assert_equal(1, subject.fills) + end + + # Task 10's 2026-09-13 amendment, seen from the vocabulary's side: the count the caller asked + # for is the count the fill hook receives, so a wrapping source can ask its upstream for it. + test "read_into hands its own count to the fill hook" do + asked = [] + subject = Class.new(Scripted) do + define_method(:fill) do |min_bytes| + asked << min_bytes + super(min_bytes) + end + end.new("abc") + + subject.read_into(+"".b, count: 4096) + + assert_equal([4096], asked) + end + + # IO-3: rejected BEFORE any I/O and with no partial side effect. + class Rejections < DexpaceTestCase + include Sources + + test "read_into rejects a negative count naming the argument, before any I/O" do + subject = source("abc") + + error = assert_raises(Dexpace::InvalidArgumentError) { subject.read_into(+"".b, count: -1) } + + assert_includes(error.message, "count") + assert_includes(error.message, "-1") + assert_equal(0, subject.fills) + assert_equal("abc", subject.read_exactly(3)) + end + + test "read_into rejects a non-Integer count" do + error = assert_raises(Dexpace::InvalidArgumentError) do + source("abc").read_into(+"".b, count: 2.5) + end + + assert_includes(error.message, "count") + end + + # A UTF-8 destination is rejected because appending a non-ASCII payload to a BINARY String + # silently retags the RESULT to UTF-8 while an ASCII-only one does not, so the destination's + # final tag would depend on the payload's content. + test "read_into rejects a non-BINARY destination before any I/O" do + subject = source("abc") + + error = assert_raises(Dexpace::InvalidArgumentError) { subject.read_into(+"", count: 3) } + + assert_includes(error.message, "ASCII-8BIT") + assert_equal(0, subject.fills) + end + + # A FrozenError from deep inside is a crash, not a rejection. + test "read_into rejects a frozen destination before any I/O" do + error = assert_raises(Dexpace::InvalidArgumentError) do + source("abc").read_into("seed".b.freeze, count: 3) + end + + assert_includes(error.message, "frozen") + end + + test "read_into rejects a destination that is not a String" do + assert_raises(Dexpace::InvalidArgumentError) { source("abc").read_into(:nope, count: 1) } + end + end + + # ---- IO-11..IO-13, IO-15: the typed reads -------------------------------------------------- + class Typed < DexpaceTestCase + include Sources + + test "read_exactly returns exactly count bytes across a chunk boundary" do + assert_equal("abcd", source("ab", "cd", "ef").read_exactly(4)) + end + + # IO-12: "MUST NOT return a short result". And it consumes nothing when it raises, so the + # source is still usable -- the no-partial-side-effect rule. + test "read_exactly raises at end of stream and consumes nothing" do + subject = source("abc") + + assert_raises(Dexpace::EndOfStreamError) { subject.read_exactly(4) } + assert_equal("abc", subject.read_exactly(3)) + end + + test "read_exactly(0) is an empty BINARY String" do + result = source.read_exactly(0) + + assert_equal("", result) + assert_equal(::Encoding::BINARY, result.encoding) + end + + test "read_exactly returns BINARY and never a frozen store chunk" do + result = source("héllo".b).read_exactly(3) + + assert_equal(::Encoding::BINARY, result.encoding) + refute_predicate(result, :frozen?) + end + + test "readbyte returns an unsigned 0..255 and raises at end of stream" do + subject = source("\xFF".b) + + assert_equal(255, subject.readbyte) + assert_raises(Dexpace::EndOfStreamError) { subject.readbyte } + end + + test "getbyte returns nil at end of stream, which is Ruby's own spelling" do + subject = source("A") + + assert_equal(65, subject.getbyte) + assert_nil(subject.getbyte) + end + + # IO-11: readByteArray() with no count returns all remaining bytes, and an empty result when + # already exhausted. + test "read with no length drains everything and returns an empty String at end of stream" do + subject = source("ab", "cd") + + assert_equal("abcd", subject.read) + assert_equal("", subject.read) + end + + # IO-15, including the explicit "skip(0) MUST be a no-op even at/after EOF". + test "skip advances past exactly count bytes" do + subject = source("abcdef") + subject.skip(2) + + assert_equal("cdef", subject.read) + end + + test "skip raises when fewer bytes remain, and skip(0) is a no-op at and after EOF" do + subject = source("ab") + + assert_raises(Dexpace::EndOfStreamError) { subject.skip(5) } + subject.read + + assert_nil(subject.skip(0)) + end + + test "skip spans a chunk boundary and consumes nothing when it raises" do + subject = source("ab", "cd") + + assert_raises(Dexpace::EndOfStreamError) { subject.skip(5) } + subject.skip(3) + + assert_equal("d", subject.read) + end + + test "eof? is false while bytes remain and true once they are gone" do + subject = source("a") + + refute_predicate(subject, :eof?) + subject.read_exactly(1) + + assert_predicate(subject, :eof?) + end + + # Phase 1's finding: Minitest's assert_predicate sends past `private` on the 3.2 floor, so + # visibility is asserted with respond_to?. + test "eof? is public and the hooks are not" do + subject = source("a") + + assert_respond_to(subject, :eof?) + refute_respond_to(subject, :fill) + refute_respond_to(subject, :store_append) + refute_respond_to(subject, :dexpace_release_views) + end + end + + # ---- IO-13: the explicit-charset reads ----------------------------------------------------- + class Charsets < DexpaceTestCase + include Sources + + # IO-13, and the only decode in 3a is a retag: no replacement policy and no charset default, + # because HTTP-42's single decode boundary is 3b's Response#body_string. + test "read_utf8 retags the drained bytes as UTF-8, non-ASCII round trip" do + result = source("héllo".b).read_utf8 + + assert_equal(::Encoding::UTF_8, result.encoding) + assert_equal("héllo", result) + end + + # count is a BYTE count, not a character count -- P3-9's keyword, and the design's own word. + test "read_utf8 takes a byte count, not a character count" do + assert_equal(2, source("héllo".b).read_utf8(count: 2).bytesize) + end + + test "read_string retags with the encoding the caller named" do + result = source("caf\xE9".b).read_string(::Encoding::ISO_8859_1) + + assert_equal(::Encoding::ISO_8859_1, result.encoding) + assert_equal("café", result.encode(::Encoding::UTF_8)) + end + + test "read_string accepts an encoding name and rejects an unknown one" do + assert_equal(::Encoding::UTF_8, source("ab").read_string("UTF-8").encoding) + error = assert_raises(Dexpace::InvalidArgumentError) { source("ab").read_string("no-such") } + + assert_includes(error.message, "no-such") + end + + test "read_string rejects the encoding before it touches the stream" do + subject = source("ab") + + assert_raises(Dexpace::InvalidArgumentError) { subject.read_string("no-such") } + assert_equal(0, subject.fills) + end + + test "read_string with a count raises at end of stream rather than returning short" do + assert_raises(Dexpace::EndOfStreamError) { source("ab").read_string("UTF-8", count: 5) } + end + + test "read_string validates nothing: an invalid sequence is the caller's question" do + result = source("\xFF".b).read_utf8 + + assert_equal(::Encoding::UTF_8, result.encoding) + refute_predicate(result, :valid_encoding?) + end + end + + # ---- IO-9: the ceiling --------------------------------------------------------------------- + class Ceiling < DexpaceTestCase + include Sources + + # The guard is checked against the requested or known count BEFORE anything is allocated, so + # these refuse while allocating nothing at all. + test "read_exactly refuses a count over the ceiling without allocating" do + subject = source("ab") + + error = assert_raises(Dexpace::StreamError) do + subject.read_exactly(Dexpace::IO::MAX_MATERIALIZED_BYTES + 1) + end + + assert_includes(error.message, "MAX_MATERIALIZED_BYTES") + assert_includes(error.message, "#read_into") + assert_equal(0, subject.fills) + end + + test "read_string and read_utf8 refuse a count over the ceiling" do + over = Dexpace::IO::MAX_MATERIALIZED_BYTES + 1 + + assert_raises(Dexpace::StreamError) { source("ab").read_string("UTF-8", count: over) } + assert_raises(Dexpace::StreamError) { source("ab").read_utf8(count: over) } + end + + # IO-21 makes over-range slice CONSTRUCTION lazy, so the ceiling fires on the read and never + # on the construction (P3-4). This asserts exactly that ordering. + test "an over-ceiling slice constructs successfully and refuses on the read" do + subject = source("ab") + view = subject.slice(offset: 0, count: Dexpace::IO::MAX_MATERIALIZED_BYTES + 1) + + assert_instance_of(Dexpace::IO::BufferedSource, view) + assert_raises(Dexpace::StreamError) { view.read } + end + + test "a slice inside the ceiling drains normally" do + assert_equal("ab", source("ab").slice(offset: 0, count: 1024).read) + end + end + + # ---- IO-14: the line machine --------------------------------------------------------------- + class Lines < DexpaceTestCase + include Sources + + test "read_line_utf8 treats \\n as a terminator and does not return it" do + subject = source("one\ntwo\n") + + assert_equal("one", subject.read_line_utf8) + assert_equal("two", subject.read_line_utf8) + assert_nil(subject.read_line_utf8) + end + + test "read_line_utf8 treats \\r\\n as a terminator" do + assert_equal("one", source("one\r\ntwo").read_line_utf8) + end + + # IO-14: "a lone '\\r' not followed by '\\n' MUST be kept as part of the line's content". + test "read_line_utf8 keeps a lone carriage return as content" do + assert_equal("a\rb", source("a\rb\n").read_line_utf8) + end + + test "read_line_utf8 returns a final unterminated line as-is" do + subject = source("tail") + + assert_equal("tail", subject.read_line_utf8) + assert_nil(subject.read_line_utf8) + end + + test "read_line_utf8 returns nil when exhausted before any byte" do + assert_nil(source.read_line_utf8) + end + + test "read_line_utf8 returns an empty line for a bare terminator" do + assert_equal("", source("\nx").read_line_utf8) + end + + test "read_line_utf8 spans chunk boundaries and returns UTF-8" do + subject = source("hél".b, "lo\nrest") + + line = subject.read_line_utf8 + + assert_equal(::Encoding::UTF_8, line.encoding) + assert_equal("héllo", line) + end + + # The terminator split across two chunks: the "\\r" arrives, the "\\n" has not yet. + test "read_line_utf8 sees a CR and LF split across chunks as one terminator" do + subject = source("ab\r", "\ncd") + + assert_equal("ab", subject.read_line_utf8) + assert_equal("cd", subject.read_line_utf8) + end + + test "read_line_utf8 consumes the terminator, so the next read starts after it" do + subject = source("ab\r\ncd") + subject.read_line_utf8 + + assert_equal("cd", subject.read) + end + + # A bounded property test (styleguide 11.7, phase 0's #sample). The generator mixes every + # terminator IO-14 names, so the machine is exercised on inputs nobody wrote by hand. + test "read_line_utf8 reconstructs any mix of terminators" do + # No pool entry ends with a carriage return, because "x\\r" + "\\n" is a \\r\\n terminator by + # IO-14 and would make the generator, not the machine, wrong. + pool = ["", "a", "ab", "a\rb", "\rx", "x\ry", "é", "hé\rllo"].freeze + + sample(count: 48) do |rng| + lines = Array.new(rng.rand(1..5)) { pool.sample(random: rng) } + terminators = Array.new(lines.length) { ["\n", "\r\n"].sample(random: rng) } + text = lines.zip(terminators).map(&:join).join + expected = lines.dup + if rng.rand(2).zero? + text = text[0...-terminators.last.length] + expected.pop if expected.last.empty? + end + + subject = source(*text.b.chars.each_slice(rng.rand(1..4)).map(&:join)) + read = [] + while (line = subject.read_line_utf8) + read << line + end + + assert_equal(expected, read) + end + end + end + + # ---- IO-16: the host-native bridge --------------------------------------------------------- + class Bridge < DexpaceTestCase + include Sources + + # THE assertion the whole sub-phase turns on. IO.copy_stream hands #readpartial ONE buffer + # that it reuses on every call and expects overwritten; IO-1's primitive appends. Both are + # asserted here against the same non-empty BINARY buffer in one test, so a later + # "simplification" that merges them turns red. + test "read overwrites outbuf while read_into appends to it" do + buffer = +"seed".b + + source("abcd").read(2, buffer) + + assert_equal("ab", buffer) + + source("wxyz").read_into(buffer, count: 2) + + assert_equal("abwx", buffer) + end + + test "readpartial overwrites outbuf too" do + buffer = +"seed".b + + source("abcd").readpartial(2, buffer) + + assert_equal("ab", buffer) + end + + # P3-2: the bridge signals end of stream the host-native way, because IO-16's own word is + # "host-native" and -1 is the reference host's InputStream convention. + test "read returns nil at end of stream for a positive length, and clears outbuf" do + buffer = +"seed".b + + assert_nil(source.read(4, buffer)) + assert_equal("", buffer) + end + + test "readpartial raises Dexpace::EndOfStreamError at end of stream" do + assert_raises(Dexpace::EndOfStreamError) { source.readpartial(4) } + end + + # P3-13. Ruby's own readers disagree with each other and across the supported floor: + # ::IO#read PRESERVES the destination's tag on 3.2.11, 3.4.10 and 4.0.6, while StringIO#read + # changed at exactly Ruby 3.4 -- ASCII-8BIT on 3.2.11, UTF-8 from 3.4.10. 3a pins BINARY + # instead, so the answer is the same on every matrix row and behind every backing stream. + # IO.copy_stream does not care, which is what keeps the bridge claim true. + test "read and readpartial leave outbuf tagged BINARY whatever it arrived as" do + utf8_buffer = +"" + source("héllo".b).read(2, utf8_buffer) + + assert_equal(::Encoding::BINARY, utf8_buffer.encoding) + + other = +"" + source("héllo".b).readpartial(2, other) + + assert_equal(::Encoding::BINARY, other.encoding) + end + + test "read(0) is an empty String and readpartial(0) never raises" do + assert_equal("", source.read(0)) + assert_equal("", source.readpartial(0)) + end + + # Ruby's own split, which is what makes the bridge a bridge: #read(n) keeps filling until it + # has n bytes or the stream ends, while #readpartial returns what is already available. + test "read fills up to length while readpartial returns what is available" do + assert_equal("abcd", source("ab", "cd").read(4)) + assert_equal("ab", source("ab", "cd").readpartial(4)) + end + + test "read returns a short result at end of stream and nil only when nothing remained" do + subject = source("ab") + + assert_equal("ab", subject.read(4)) + assert_nil(subject.read(4)) + end + + test "readpartial hands its maxlen to the fill hook and read rejects a negative length" do + asked = [] + subject = Class.new(Scripted) do + define_method(:fill) do |min_bytes| + asked << min_bytes + super(min_bytes) + end + end.new("abc") + subject.readpartial(512) + + assert_equal([512], asked) + assert_raises(Dexpace::InvalidArgumentError) { subject.read(-1) } + assert_raises(Dexpace::InvalidArgumentError) { subject.readpartial(-1) } + end + end + + # ---- §10.2: #each -------------------------------------------------------------------------- + class Each < DexpaceTestCase + include Sources + + # The granularity is whatever the upstream produced, because .over must preserve the wrapped + # body's own chunking for BODY-17's byte-exact mirroring to mean what it says. + test "each yields the upstream's own chunks, in order, tagged BINARY" do + chunks = [] + encodings = [] + + source("ab", "cde", "f").each do |chunk| + chunks << chunk.dup + encodings << chunk.encoding + end + + assert_equal(%w[ab cde f], chunks) + assert_equal([::Encoding::BINARY] * 3, encodings) + end + + test "each without a block returns an Enumerator" do + assert_kind_of(::Enumerator, source("ab").each) + assert_equal(%w[ab cd], source("ab", "cd").each.to_a) + end + + test "each resumes from the cursor when bytes were already consumed" do + subject = source("abcd", "ef") + subject.read_exactly(1) + + assert_equal(%w[bcd ef], subject.each.to_a) + end + + test "each consumes what it yields, so a second pass yields nothing" do + subject = source("ab", "cd") + subject.each { |_| nil } + + assert_empty(subject.each.to_a) + assert_predicate(subject, :eof?) + end + + # The two count-less readers ask the fill hook for a segment, never for one byte (Task 10's + # 2026-09-13 amendment); 64 KiB is READ_SEGMENT_BYTES, private and not NFR-4 surface. + test "each and a count-less read ask the fill hook for a segment, not a byte" do + asked = [] + subject = Class.new(Scripted) do + define_method(:fill) do |min_bytes| + asked << min_bytes + super(min_bytes) + end + end.new("ab") + subject.each { |_| nil } + subject.read + + assert_equal([64 * 1024, 64 * 1024, 64 * 1024], asked) + end + end + + # ---- IO-19..IO-21, IO-23: the views' shape ------------------------------------------------- + class Views < DexpaceTestCase + include Sources + + # IO-19: "a non-consuming view over the whole remaining source such that reads from the peek + # view do not advance the original source's cursor". + test "peek does not advance the parent cursor" do + subject = source("abcdef") + + assert_equal("abcdef", subject.peek.read) + assert_equal("abcdef", subject.read) + end + + test "peek is a BufferedSource in view mode, not a new public type" do + view = source("ab").peek + + assert_instance_of(Dexpace::IO::BufferedSource, view) + assert_predicate(view, :view?) + refute_predicate(view, :owns_upstream?) + end + + # IO-19's "whole remaining source" reaches past what is currently buffered: the view drives + # the parent's fill (three chunks, plus the probes that find the end) without advancing the + # parent's cursor. + test "peek reaches bytes the parent has not yet pulled from its upstream" do + subject = source("ab", "cd", "ef") + + assert_equal("abcdef", subject.peek.read) + assert_operator(subject.fills, :>=, 3) + assert_equal("abcdef", subject.read) + end + + # IO-20. + test "slice exposes at most count bytes starting offset bytes ahead of the cursor" do + subject = source("abcdef") + + assert_equal("cd", subject.slice(offset: 2, count: 2).read) + assert_equal("abcdef", subject.read) + end + + test "reading past a slice window behaves as end of window" do + subject = source("abcdef") + view = subject.slice(offset: 0, count: 2) + + assert_equal("ab", view.read) + assert_equal("", view.read) + assert_raises(Dexpace::EndOfStreamError) { view.read_exactly(1) } + assert_nil(view.read_line_utf8) + assert_predicate(view, :eof?) + end + + # IO-21: overflow is LAZY. Construction succeeds; the overflow surfaces on first read. + test "a slice whose offset exceeds the source constructs successfully and is empty on read" do + subject = source("abc") + view = subject.slice(offset: 99, count: 5) + + assert_instance_of(Dexpace::IO::BufferedSource, view) + assert_equal("", view.read) + assert_nil(view.read_line_utf8) + assert_raises(Dexpace::EndOfStreamError) { view.readbyte } + end + + test "slice rejects a negative offset or count eagerly at construction" do + subject = source("abc") + + assert_raises(Dexpace::InvalidArgumentError) { subject.slice(offset: -1, count: 1) } + assert_raises(Dexpace::InvalidArgumentError) { subject.slice(offset: 0, count: -1) } + assert_raises(Dexpace::InvalidArgumentError) { subject.slice(offset: 0.5, count: 1) } + end + + # IO-23: mutually independent cursors and budgets. + test "two slices of one source are mutually independent" do + subject = source("abcdef") + first = subject.slice(offset: 0, count: 4) + second = subject.slice(offset: 0, count: 4) + + assert_equal("ab", first.read(2)) + assert_equal("abcd", second.read) + assert_equal("cd", first.read) + end + + # IO-23: "a slice-of-a-slice MUST compose offsets additively and cap its window at the outer + # slice's remaining bytes". + test "a slice of a slice composes additively and is capped by the outer window" do + subject = source("abcdefgh") + outer = subject.slice(offset: 1, count: 4) + + assert_equal("cd", outer.slice(offset: 1, count: 2).read) + assert_equal("cde", outer.slice(offset: 1, count: 99).read) + end + + test "a view's own views compose over a partially read outer view" do + subject = source("abcdefgh") + outer = subject.slice(offset: 1, count: 5) + outer.read_exactly(2) + + assert_equal("de", outer.slice(offset: 0, count: 2).read) + assert_equal("def", outer.read) + end + end + + # ---- IO-22, IO-23 (property), IO-24: the views' lifecycle ---------------------------------- + class ViewLifecycle < DexpaceTestCase + include Sources + + test "slice composition holds over random offsets and budgets" do + sample(count: 48) do |rng| + text = ("a".."z").to_a.join + outer_offset = rng.rand(0..6) + outer_count = rng.rand(0..8) + inner_offset = rng.rand(0..4) + inner_count = rng.rand(0..8) + + outer = source(text.b).slice(offset: outer_offset, count: outer_count) + window = text.byteslice(outer_offset, outer_count).to_s + # Not #clamp: the window can be shorter than the inner offset, and clamp raises when its + # own maximum is below its minimum. IO-23 caps at the outer slice's REMAINING bytes, + # which is zero once the offset is past the end. + available = [window.bytesize - inner_offset, 0].max + expected = window.byteslice(inner_offset, [inner_count, available].min).to_s + + assert_equal(expected, outer.slice(offset: inner_offset, count: inner_count).read) + end + end + + # P3-5 applied recursively, which it is because a view IS a BufferedSource: reading the OUTER + # slice moves that slice's own cursor past the inner slice's pin, so the inner slice fails + # loudly -- even though the underlying source has consumed nothing. Pinned rather than left + # to be discovered, because IO-23's "mutually independent" is about slices of the SAME source + # and a reader can take it further than it goes. + test "reading an outer slice invalidates an inner slice taken from it" do + subject = source("abcdefgh") + outer = subject.slice(offset: 1, count: 4) + inner = outer.slice(offset: 1, count: 2) + + assert_equal("bcde", outer.read) + assert_raises(Dexpace::ClosedError) { inner.read } + end + + # IO-22, first half. + test "closing a slice closes neither the parent nor its cursor" do + subject = source("abcdef") + view = subject.slice(offset: 0, count: 2) + view.read + view.close + + refute_predicate(subject, :closed?) + assert_equal("abcdef", subject.read) + end + + # IO-24: "Reading from a slice AFTER it has been explicitly closed MUST fail loudly (a state + # error) for every read form, distinct from normal EOF." + test "every read form on an explicitly closed slice raises ClosedError, not an EOF error" do + view = source("abcdef").slice(offset: 0, count: 4) + view.close + + assert_raises(Dexpace::ClosedError) { view.read } + assert_raises(Dexpace::ClosedError) { view.read_exactly(1) } + assert_raises(Dexpace::ClosedError) { view.readbyte } + assert_raises(Dexpace::ClosedError) { view.getbyte } + assert_raises(Dexpace::ClosedError) { view.readpartial(1) } + assert_raises(Dexpace::ClosedError) { view.read_line_utf8 } + assert_raises(Dexpace::ClosedError) { view.read_utf8 } + assert_raises(Dexpace::ClosedError) { view.skip(1) } + assert_raises(Dexpace::ClosedError) { view.eof? } + assert_raises(Dexpace::ClosedError) { view.read_into(+"".b, count: 1) } + assert_raises(Dexpace::ClosedError) { view.each { |_| nil } } + assert_raises(Dexpace::ClosedError) { view.peek } + assert_raises(Dexpace::ClosedError) { view.slice(offset: 0, count: 1) } + end + + # IO-22, second half: "closing the parent source MUST invalidate every outstanding slice + # derived from it so that subsequent reads on those slices fail loudly ... never returning + # stale or arbitrary bytes". + test "closing the parent invalidates every outstanding slice" do + subject = source("abcdef") + first = subject.slice(offset: 0, count: 2) + second = subject.peek + subject.close + + assert_raises(Dexpace::ClosedError) { first.read } + assert_raises(Dexpace::ClosedError) { second.read } + end + + test "closing the parent invalidates a slice of a slice, transitively" do + subject = source("abcdef") + inner = subject.slice(offset: 0, count: 4).slice(offset: 1, count: 2) + subject.close + + assert_raises(Dexpace::ClosedError) { inner.read } + end + + # P3-5, which the specification does not state and 3a therefore does. IO-22's "never + # returning stale or arbitrary bytes" is the only normative anchor and it points one way. + test "a parent read past a live view's pin fails that view loudly, never serving other bytes" do + subject = source("abcdef") + view = subject.slice(offset: 0, count: 4) + subject.read_exactly(3) + + error = assert_raises(Dexpace::ClosedError) { view.read } + + assert_includes(error.message, "behind") + end + + test "a view that has already pulled its window is unaffected by a later parent read" do + subject = source("abcdef") + view = subject.slice(offset: 0, count: 4) + + assert_equal("abcd", view.read) + subject.read_exactly(5) + + assert_equal("", view.read) + end + + test "a closed view releases its registration with the parent" do + subject = source("abcdef") + view = subject.slice(offset: 0, count: 2) + view.close + registered = subject.instance_variable_get(:@dexpace_views) + + assert_empty(registered) + end + end + + # ---- the includer contract ----------------------------------------------------------------- + class Includer < DexpaceTestCase + test "an includer that never calls initialize_typed_reads fails loudly" do + unready = Class.new do + include Dexpace::IO::TypedReads + include Dexpace::Closeable + + def initialize = initialize_closeable(owned: true) + end.new + + assert_raises(Dexpace::SeamError) { unready.read } + end + + test "an includer that supplies no fill hook fails loudly" do + hookless = Class.new do + include Dexpace::IO::TypedReads + include Dexpace::Closeable + + def initialize + initialize_closeable(owned: true) + initialize_typed_reads + end + end.new + + assert_raises(::NotImplementedError) { hookless.read } + end + + test "an includer without Closeable fails loudly at initialisation" do + klass = Class.new do + include Dexpace::IO::TypedReads + + def initialize = initialize_typed_reads + end + + error = assert_raises(Dexpace::SeamError) { klass.new } + + assert_includes(error.message, "Dexpace::Closeable") + end + end +end diff --git a/gems/dexpace-core/test/dexpace/io/typed_writes_test.rb b/gems/dexpace-core/test/dexpace/io/typed_writes_test.rb new file mode 100644 index 0000000..b67e677 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/io/typed_writes_test.rb @@ -0,0 +1,336 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../support/fake_source" +require "dexpace" + +# IO-4, IO-5, IO-13 (write side), IO-16 (writable bridge), IO-17, IO-18. +# +# Written against a trivial in-test includer whose #deliver appends to a String, so the write +# vocabulary is exercised with no destination at all. +# +# One class per behaviour group, because Metrics/ClassLength caps a class at 100 lines: the +# bridge and #write_from here, then Pump, Charsets and Includer below. +class DexpaceTypedWritesTest < DexpaceTestCase + # The Ruby shape of the reference's BufferedSink interface: supply #deliver, get everything. + class Collector + include Dexpace::IO::TypedWrites + include Dexpace::Closeable + + attr_reader :delivered + + def initialize + @delivered = [] + initialize_closeable(owned: true) + initialize_typed_writes + end + + def written = @delivered.join.b + + private + + def deliver(string) + @delivered << string + nil + end + + def release = nil + end + + # One shared factory for every class below, so every sink under test is shaped the same. + module Sinks + def sink = Collector.new + end + include Sinks + + # ---- IO-16: the writable bridge ------------------------------------------------------------ + + # IO-16's writable half, and P3-9's stated exception: a positional splat exactly like ::IO#write, + # because being call-compatible with ::IO is the bridge's entire purpose -- a keyword here would + # make IO.copy_stream fail. + test "write takes a splat of Strings and returns the byte count" do + subject = sink + + assert_equal(5, subject.write("ab", "cde")) + assert_equal("abcde", subject.written) + end + + test "write returns the BYTE count, not the character count" do + assert_equal("é".bytesize, sink.write("é")) + end + + test "write hands the hook frozen BINARY bytes even for a non-ASCII UTF-8 argument" do + subject = sink + subject.write("é") + + assert_equal(::Encoding::BINARY, subject.delivered.first.encoding) + assert_predicate(subject.delivered.first, :frozen?) + end + + test "write copies a caller-mutable String, so a reused buffer cannot corrupt what was written" do + subject = sink + buffer = +"ab".b + subject.write(buffer) + buffer.replace("zz") + + assert_equal("ab", subject.written) + end + + test "write keeps an already-frozen BINARY String by identity and skips an empty one" do + subject = sink + frozen = "ab".b.freeze + + assert_equal(2, subject.write(frozen, "")) + assert_same(frozen, subject.delivered.first) + assert_equal(1, subject.delivered.length) + end + + test "write rejects a non-String argument" do + assert_raises(Dexpace::InvalidArgumentError) { sink.write(42) } + end + + # ---- IO-4: write_from ---------------------------------------------------------------------- + class WriteFrom < DexpaceTestCase + include Sinks + + # IO-4: "MUST remove exactly byteCount bytes from the HEAD of the source buffer and push them + # downstream". + test "write_from removes exactly count bytes from the head of the buffer" do + subject = sink + buffer = Dexpace::IO::Buffer.new + buffer.write("abcdef") + + subject.write_from(buffer, count: 3) + + assert_equal("abc", subject.written) + assert_equal(3, buffer.bytesize) + assert_equal("def", buffer.read) + end + + # IO-4: "if the source holds fewer than byteCount bytes this MUST fail with an I/O error rather + # than write a short/partial amount." And nothing is consumed, so the buffer is still usable. + test "write_from fails with an I/O error rather than writing short, and consumes nothing" do + subject = sink + buffer = Dexpace::IO::Buffer.new + buffer.write("ab") + + error = assert_raises(Dexpace::StreamError) { subject.write_from(buffer, count: 5) } + + assert_includes(error.message, "2") + assert_includes(error.message, "5") + assert_empty(subject.written) + assert_equal("ab", buffer.read) + end + + test "write_from rejects a negative or non-Integer count before touching the buffer" do + buffer = Dexpace::IO::Buffer.new + buffer.write("abc") + + assert_raises(Dexpace::InvalidArgumentError) { sink.write_from(buffer, count: -1) } + assert_raises(Dexpace::InvalidArgumentError) { sink.write_from(buffer, count: 1.5) } + assert_equal(3, buffer.bytesize) + end + + test "write_from with a zero count is a no-op" do + subject = sink + buffer = Dexpace::IO::Buffer.new + + assert_nil(subject.write_from(buffer, count: 0)) + assert_empty(subject.written) + end + + test "write_from rejects something that is not a Buffer" do + assert_raises(Dexpace::InvalidArgumentError) { sink.write_from("abc", count: 1) } + end + end + + # ---- IO-17: the pump ----------------------------------------------------------------------- + class Pump < DexpaceTestCase + include Sinks + + # IO-17: "MUST pump the source to exhaustion into the sink and return the total number of + # bytes transferred; it MUST terminate only on a -1 (EOF) read." + test "write_all pumps to exhaustion and returns the total" do + subject = sink + source = FakeSource.new("ab", "cde", -1) + + assert_equal(5, subject.write_all(source)) + assert_equal("abcde", subject.written) + end + + test "write_all terminates on -1 and does not keep asking" do + source = FakeSource.new("ab", -1) + sink.write_all(source) + + assert_equal(2, source.calls.length) + assert_equal([64 * 1024] * 2, source.calls) + end + + # IO-17: "a read that returns 0 for a non-zero requested count MUST be treated as a source + # contract violation and raised as an I/O error (not tolerated as EOF and not spun on + # forever)." No real Ruby stream produces this, which is why FakeSource exists. + # + # The check is UNCONDITIONAL rather than scoped to a "foreign" source: design §10.1 retired + # the adapter, so "adapter-native" has no subject in this port, and a correct source never + # returns 0 for a positive count anyway. + test "write_all raises on a zero read for a positive count and does not spin" do + subject = sink + source = FakeSource.new("ab", 0, "cd") + + error = assert_raises(Dexpace::StreamError) { subject.write_all(source) } + + assert_includes(error.message, "IO-17") + assert_equal(2, source.calls.length) + assert_equal("ab", subject.written) + end + + test "write_all lets a source's own failure propagate unchanged" do + boom = Class.new(::StandardError) + + assert_raises(boom) { sink.write_all(FakeSource.new("ab", boom.new("no"))) } + end + + test "write_all rejects an object that is not a source" do + error = assert_raises(Dexpace::InvalidArgumentError) { sink.write_all(Object.new) } + + assert_includes(error.message, "#read_into") + end + + test "write_all drives a real BufferedSource end to end" do + subject = sink + + assert_equal(6, subject.write_all(Dexpace::IO::BufferedSource.of_bytes("abcdef"))) + assert_equal("abcdef", subject.written) + end + + test "write_all of an already exhausted source transfers nothing and returns 0" do + assert_equal(0, sink.write_all(FakeSource.new(-1))) + end + end + + # ---- IO-13: the write-side encodings, and IO-5/IO-18 --------------------------------------- + class Charsets < DexpaceTestCase + include Sinks + + test "write_utf8 writes the UTF-8 bytes of the string" do + subject = sink + subject.write_utf8("héllo") + + assert_equal("héllo".b, subject.written) + end + + # P3-9: `range` is a CHARACTER range over the String -- the reference's substring form -- + # while every count elsewhere in 3a is a byte count. The non-ASCII fixture tells them apart. + test "write_utf8 range is a character range, not a byte range" do + subject = sink + subject.write_utf8("héllo", range: 0..1) + + assert_equal("hé".b, subject.written) + end + + test "write_utf8 rejects a range outside the string, and a non-String with or without one" do + assert_raises(Dexpace::InvalidArgumentError) { sink.write_utf8("ab", range: 9..12) } + assert_raises(Dexpace::InvalidArgumentError) { sink.write_utf8(:ab) } + assert_raises(Dexpace::InvalidArgumentError) { sink.write_utf8(:ab, range: 0..1) } + end + + test "write_string encodes into the charset the caller named" do + subject = sink + subject.write_string("café", encoding: ::Encoding::ISO_8859_1) + + assert_equal("caf\xE9".b, subject.written) + end + + test "write_string accepts an encoding name and rejects an unknown one" do + subject = sink + subject.write_string("é", encoding: "UTF-8") + + assert_equal("é".b, subject.written) + assert_raises(Dexpace::InvalidArgumentError) { sink.write_string("ab", encoding: "no-such") } + end + + test "write_string rejects text the target charset cannot represent" do + error = assert_raises(Dexpace::InvalidArgumentError) do + sink.write_string("→", encoding: ::Encoding::ISO_8859_1) + end + + assert_includes(error.message, "ISO-8859-1") + end + + test "write_string of an empty string delivers nothing" do + subject = sink + + assert_nil(subject.write_string("", encoding: "UTF-8")) + assert_empty(subject.delivered) + end + + # IO-18: "On a pure in-memory buffer both MAY be no-ops that simply return self." + test "emit and flush return self" do + subject = sink + + assert_same(subject, subject.emit) + assert_same(subject, subject.flush) + end + end + + # ---- the includer contract ----------------------------------------------------------------- + class Includer < DexpaceTestCase + include Sinks + + test "an includer that never calls initialize_typed_writes fails loudly" do + unready = Class.new do + include Dexpace::IO::TypedWrites + include Dexpace::Closeable + + def initialize = initialize_closeable(owned: true) + end.new + + assert_raises(Dexpace::SeamError) { unready.write("ab") } + end + + test "an includer that supplies no deliver hook fails loudly" do + hookless = Class.new do + include Dexpace::IO::TypedWrites + include Dexpace::Closeable + + def initialize + initialize_closeable(owned: true) + initialize_typed_writes + end + end.new + + assert_raises(::NotImplementedError) { hookless.write("ab") } + end + + test "an includer without Closeable fails loudly at initialisation" do + klass = Class.new do + include Dexpace::IO::TypedWrites + + def initialize = initialize_typed_writes + end + + assert_raises(Dexpace::SeamError) { klass.new } + end + + test "the write hooks are private and the vocabulary is public" do + subject = sink + + assert_respond_to(subject, :write_all) + refute_respond_to(subject, :deliver) + refute_respond_to(subject, :push_one_level) + refute_respond_to(subject, :push_all) + end + + # A closed includer that does not exempt itself refuses every write form (IO-42); Buffer is + # the one that does, and buffer_test.rb proves that side. + test "a closed includer refuses to write unless it exempts itself" do + subject = sink + subject.close + + assert_raises(Dexpace::ClosedError) { subject.write("ab") } + assert_raises(Dexpace::ClosedError) { subject.emit } + assert_raises(Dexpace::ClosedError) { subject.flush } + end + end +end diff --git a/gems/dexpace-core/test/dexpace/io_test.rb b/gems/dexpace-core/test/dexpace/io_test.rb new file mode 100644 index 0000000..320cb81 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/io_test.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../test_helper" +require "dexpace" + +# IO-9, and P3-8's naming of the constant. +class DexpaceIOTest < DexpaceTestCase + test "MAX_MATERIALIZED_BYTES is design §3.1's 64 MiB, chosen and not derived" do + assert_equal(64 * 1024 * 1024, Dexpace::IO::MAX_MATERIALIZED_BYTES) + assert_predicate(Dexpace::IO::MAX_MATERIALIZED_BYTES, :frozen?) + end + + # R5 is 3b's and phase 5 owns the configuration source. Nothing in 3a reads the ceiling from a + # keyword, so this asserts the absence that phase 2's deadline: precedent (P2-5) depends on: + # adding an optional keyword later widens a signature, and NFR-4 is not prejudiced. + test "no 3a operation takes a max_materialized_bytes keyword" do + methods = [Dexpace::IO::Buffer.instance_method(:snapshot), + Dexpace::IO::TypedReads.instance_method(:read_exactly), + Dexpace::IO::TypedReads.instance_method(:read_string),] + keywords = methods.flat_map { |method| method.parameters.map(&:last) } + + refute_includes(keywords, :max_materialized_bytes) + end + + # Pins the module's whole constant list, so a later phase that adds a class to Dexpace::IO has + # to say so here as well as in the surface manifest. + test "Dexpace::IO is a module and holds the streaming classes" do + assert_kind_of(::Module, Dexpace::IO) + assert_equal(%i[Buffer BufferedSink BufferedSource MAX_MATERIALIZED_BYTES TeeSink TypedReads + TypedWrites].sort, + Dexpace::IO.constants.sort,) + end + + # The hazard Dexpace::IO's own YARD block states, measured rather than described: inside + # `module Dexpace` a bare IO is this module and not Ruby's, so a nominal type test on a real + # ::IO is silently false. This is why core writes `::IO` and respond_to?, never is_a?(IO), and + # why Dexpace/QualifiedCoreConstant refuses the bare name. String evals, because a block's + # constants resolve in the block's own lexical scope (verified on 3.2.11, 3.4.10 and 4.0.6), + # and only a string eval gives the receiver's. + test "a bare IO inside module Dexpace is Dexpace::IO, not ::IO" do + inside = Dexpace.module_eval("IO", __FILE__, __LINE__) + + assert_same(Dexpace::IO, inside) + refute_same(::IO, inside) + assert_same(::IO, Dexpace.module_eval("::IO", __FILE__, __LINE__)) + refute(Dexpace.module_eval("::IO.pipe.first.is_a?(IO)", __FILE__, __LINE__)) + end + + # The consumer half of the finding docs/first-release.md carries as a blocker: a consumer's own + # `class C; include Dexpace` puts Dexpace ahead of Object in C.ancestors, so a bare IO in C + # resolves to Dexpace::IO and `x.is_a?(IO)` is silently false for a real ::IO. Nothing + # mechanical reaches a consumer's file; the as-built documentation states it, and this pins + # that the hazard is real rather than described. + test "a consumer class that includes Dexpace sees Dexpace::IO for a bare IO" do + consumer = Class.new { include Dexpace } + reader, writer = ::IO.pipe + + assert_same(Dexpace::IO, consumer.class_eval("IO", __FILE__, __LINE__)) + refute(consumer.class_eval("::IO.pipe.first.is_a?(IO)", __FILE__, __LINE__)) + assert_kind_of(::IO, reader) + ensure + reader&.close + writer&.close + end +end diff --git a/gems/dexpace-core/test/support/fake_chunked.rb b/gems/dexpace-core/test/support/fake_chunked.rb new file mode 100644 index 0000000..1eab99a --- /dev/null +++ b/gems/dexpace-core/test/support/fake_chunked.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# Design §10.2's canonical body representation: an object responding to #each and nothing else, so +# it is exactly Dexpace::IO::_Chunked. Under this file's own frozen_string_literal pragma the +# chunks it yields from a literal array are FROZEN, which is the ordinary Rack shape and the exact +# input on which force_encoding raises (verified fact 3). +# +# `ensure_ran` records whether the #each body's ensure ran, which is §7.1's residue test, and the +# chunk list is scripted, so `FakeChunked.new("ab", "", "cd")` yields an EMPTY chunk between two +# non-empty ones -- the one input no StringIO and no IO.pipe can produce, and the one that tells +# "no bytes this time" apart from "no bytes ever". +class FakeChunked + attr_reader :ensure_ran, :yielded + + def initialize(*chunks) + @chunks = chunks + @ensure_ran = false + @yielded = 0 + end + + def each + @chunks.each do |chunk| + @yielded += 1 + yield chunk + end + ensure + @ensure_ran = true + end + + # The frozen non-ASCII literals every encoding test in 3a uses: an ASCII-only fixture would pass + # under exactly the bug (verified fact 4). + def self.frozen_utf8 + new("héllo ", "wörld") + end +end diff --git a/gems/dexpace-core/test/support/fake_sink.rb b/gems/dexpace-core/test/support/fake_sink.rb new file mode 100644 index 0000000..e35a01b --- /dev/null +++ b/gems/dexpace-core/test/support/fake_sink.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# A destination that implements #write and NOTHING else, so it is exactly Dexpace::IO::_Sink. +# Scriptable to return a SHORT count (the underlying-sink contract violation) and to raise partway +# through, which is what IO-27's "clears its staging buffer even on a failed primary write" needs; +# and it records every String it received in order, which is how IO-25's "the wire body MUST never +# be reduced or altered by the tap" is asserted byte for byte. +# +# Each script entry is consumed by one #write: nil for an ordinary full write, an Integer to +# report instead of the byte count, or an exception instance to raise before anything is recorded. +class FakeSink + attr_reader :writes + + def initialize(*script) + @script = script + @writes = [] + end + + def written + @writes.join.b + end + + def write(*strings) + payload = strings.join.b + outcome = @script.shift + raise outcome if outcome.is_a?(::Exception) + + @writes << payload + return outcome if outcome.is_a?(::Integer) + + payload.bytesize + end +end diff --git a/gems/dexpace-core/test/support/fake_source.rb b/gems/dexpace-core/test/support/fake_source.rb new file mode 100644 index 0000000..0abafad --- /dev/null +++ b/gems/dexpace-core/test/support/fake_source.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# IO-17's source-contract violation has no natural stand-in: no real Ruby stream returns 0 for a +# positive requested count. This double implements #read_into and NOTHING else, so it is exactly +# Dexpace::IO::_Source and is also what proves #write_all accepts a foreign source. +# +# Each script entry is either a String to deliver, the Integer 0 (the violation), -1 (end of +# stream), or an exception instance to raise. It records what it was asked for, so a test can +# assert the pump terminated rather than spun. +class FakeSource + attr_reader :calls + + def initialize(*script) + @script = script + @calls = [] + end + + def read_into(dest, count:) + @calls << count + outcome = @script.shift + return -1 if outcome.nil? || outcome == -1 + raise outcome if outcome.is_a?(::Exception) + return 0 if outcome == 0 # rubocop:disable Style/NumericPredicate -- a scripted sentinel, not a count + + bytes = outcome.b + dest << bytes + bytes.bytesize + end +end diff --git a/test/gates/rbs_surface_test.rb b/test/gates/rbs_surface_test.rb index 89037d0..96e7cfb 100644 --- a/test/gates/rbs_surface_test.rb +++ b/test/gates/rbs_surface_test.rb @@ -60,6 +60,14 @@ class RbsSurfaceTest < GateCase assert_empty(RbsSurface.violations([fixture("core_superclasses.rbs")])) end + # Phase 3a's two stream failure types subclass core Ruby's EOFError and IOError (P3-3) and + # IO-13's charset reads take an Encoding; all three are core Ruby, and the allowlist as phase 1 + # left it named none of them, so the first I/O signatures turned the gate red on correct code. + # Pinned so the three names stay admitted. + test "an EOFError superclass, an IOError superclass and an Encoding parameter are core Ruby" do + assert_empty(RbsSurface.violations([fixture("io_superclasses.rbs")])) + end + test "rejects a foreign method-level generic bound, and not the parameter it bounds" do found = RbsSurface.violations([fixture("method_bound.rbs")]).join("\n") From d053fd47930dabc8c500920cbe500927218d08ea Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Tue, 15 Sep 2026 21:58:22 +0300 Subject: [PATCH 6/8] test: the one-fill contract through a view, and probes that leak no pipe Nine tests. typed_reads_test.rb gains a ViewFills group over the scripted parent, whose #fills counter is the assertion: #read_into, #readpartial and #each on a peek fill the parent once and return what arrived, a slice past the buffered bytes fills until its first byte is reachable and no further, and #read(n) and #read_exactly on a view still fill to the count, which is Ruby's contract. buffered_source_test gains a Views group over a live IO.pipe with the writer left open -- a watchdog closes it after two seconds so the pre-fix shape fails on `writer.closed?` instead of hanging -- plus the count a view passes to the upstream, offset included, and the view constructor's absence from the public singleton methods (review round 0, R0-1 and R0-3). Run red against the pre-fix `ensure_buffered(behind + want)`: four ViewFills failures and the pipe test's closed writer. io_test.rb's two string-eval shadow probes each created an IO.pipe inside the eval and never closed it (R0-4); each now evals a lambda and hands it the pipe end the test owns and closes in its ensure. --- .../test/dexpace/io/buffered_source_test.rb | 60 +++++++++++++++- .../test/dexpace/io/typed_reads_test.rb | 70 ++++++++++++++++++- gems/dexpace-core/test/dexpace/io_test.rb | 15 +++- 3 files changed, 140 insertions(+), 5 deletions(-) diff --git a/gems/dexpace-core/test/dexpace/io/buffered_source_test.rb b/gems/dexpace-core/test/dexpace/io/buffered_source_test.rb index 4cb3fc4..486d084 100644 --- a/gems/dexpace-core/test/dexpace/io/buffered_source_test.rb +++ b/gems/dexpace-core/test/dexpace/io/buffered_source_test.rb @@ -13,7 +13,7 @@ # IO-38 test real rather than simulated -- a reader really does block. # # One class per behaviour group, because Metrics/ClassLength caps a class at 100 lines: ownership -# here, then Bridge, Over, Close, Threads and Counts below. +# here, then Bridge, Over, Close, Threads, Counts and Views below. class DexpaceBufferedSourceTest < DexpaceTestCase # A pipe whose two ends are closed however the block leaves. module Pipes @@ -577,4 +577,62 @@ class Counts < DexpaceTestCase assert_equal("héllo wörld".b, source.read) end end + + # ---- IO-1, IO-16, IO-19: a view over a LIVE stream returns what has arrived ------------------ + class Views < DexpaceTestCase + include Pipes + + # Review round 0, R0-1: a view asked its parent to buffer `behind + count`, so a peek over a + # pipe holding 10 bytes with the writer still open blocked on read_into(count: 100) where the + # root returned the 10 at once. The pipe is what makes it real: a reader really does block. + # If a view's read blocks for the count rather than the first byte, the watchdog closes the + # writer after two seconds so this fails on `writer.closed?` instead of hanging the run. + test "a view over a live pipe returns what has arrived, like the root" do + pipe do |reader, writer| + writer.write("0123456789") + source = Dexpace::IO::BufferedSource.wrapping(reader) + done = ::Thread::Queue.new + watchdog = ::Thread.new { writer.close if done.pop(timeout: 2).nil? } + dest = +"".b + + assert_equal(10, source.peek.read_into(dest, count: 100)) + assert_equal("0123456789".b, dest) + assert_equal("0123456789".b, source.peek.readpartial(100)) + assert_equal("0123456789".b, source.peek.each.first) + done.push(:done) + watchdog.join + + refute_predicate(writer, :closed?) + assert_equal("0123456789".b, source.read(10)) + end + end + + # The plan's Task 10 amendment holds through a view: the upstream is asked for the view's + # own count, and for a slice the bytes up to its offset on top, never for one byte. + test "a view passes its own count through to the upstream, offset included" do + asked = [] + upstream = Object.new + upstream.define_singleton_method(:readpartial) do |want| + asked << want + raise ::EOFError if asked.size > 2 + + "z" * want + end + source = Dexpace::IO::BufferedSource.wrapping(upstream) + dest = +"".b + + assert_equal(100, source.peek.read_into(dest, count: 100)) + assert_equal(50, source.slice(offset: 150, count: 50).read_into(dest, count: 4096)) + assert_equal([100, 100], asked) + end + + # The view constructor is internal (design: "internal -- no RBS signature, no YARD block"), + # so a caller cannot reach it by name; #peek and #slice are the only way to a view. + test "the view constructor is a private class method, not public surface" do + assert_raises(NoMethodError) do + Dexpace::IO::BufferedSource.__dexpace_view(parent: nil, pin: 0, window: nil) + end + refute_includes(Dexpace::IO::BufferedSource.singleton_methods, :__dexpace_view) + end + end end diff --git a/gems/dexpace-core/test/dexpace/io/typed_reads_test.rb b/gems/dexpace-core/test/dexpace/io/typed_reads_test.rb index bd64e84..d5a5838 100644 --- a/gems/dexpace-core/test/dexpace/io/typed_reads_test.rb +++ b/gems/dexpace-core/test/dexpace/io/typed_reads_test.rb @@ -11,7 +11,8 @@ # vocabulary over a real stream. # # One class per behaviour group, because Metrics/ClassLength caps a class at 100 lines: the -# primitive here, then Typed, Ceiling, Lines, Bridge, Each, Views and Includer below. +# primitive here, then Typed, Ceiling, Lines, Bridge, Each, Views, ViewFills, ViewLifecycle and +# Includer below. class DexpaceTypedReadsTest < DexpaceTestCase # The Ruby shape of the reference's BufferedSource interface: supply #fill, get everything. class Scripted @@ -694,6 +695,73 @@ class Views < DexpaceTestCase end end + # ---- IO-1, IO-16, IO-19, IO-20: the one-fill contract holds through a view ----------------- + # + # The primitive fills ONCE and returns what arrived (design R2), and the bridge carries Ruby's + # semantics through a view (R4). Before review round 0's R0-1 a view asked its parent to + # buffer `behind + count`, so #read_into, #readpartial and #each on a view blocked until the + # whole count had arrived where the root returned at once. The parent's #fills counter is the + # assertion: one fill for one read, however large the count. + class ViewFills < DexpaceTestCase + include Sources + + test "read_into on a view fills the parent once and returns what arrived" do + subject = source("0123456789", "abc") + dest = +"".b + + assert_equal(10, subject.peek.read_into(dest, count: 100)) + assert_equal("0123456789", dest) + assert_equal(1, subject.fills) + end + + # IO-16: #readpartial on a view returns what is available, never blocking to maxlen. + test "readpartial on a view returns what the parent has, not maxlen" do + subject = source("0123456789", "abc") + + assert_equal("0123456789", subject.peek.readpartial(100)) + assert_equal(1, subject.fills) + end + + test "each on a view yields the parent's first chunk before filling again" do + subject = source("ab", "cd") + + assert_equal("ab", subject.peek.each.first) + assert_equal(1, subject.fills) + end + + # A slice whose offset lies past what is buffered fills until its first byte is reachable, + # and no further: two fills reach byte 3, the read returns the one byte that arrived with it, + # and a third fill -- for the rest of the window -- would be the count-blocking shape. + test "a slice fills its parent past its offset before serving, never reporting a false EOF" do + subject = source("ab", "cd", "ef") + dest = +"".b + view = subject.slice(offset: 3, count: 2) + + assert_equal(1, view.read_into(dest, count: 100)) + assert_equal("d", dest) + assert_equal(2, subject.fills) + assert_equal(1, view.read_into(dest, count: 100)) + assert_equal("de", dest) + assert_equal(3, subject.fills) + end + + # Ruby's contract on the bridge's bulk read is untouched: #read(n) on a view blocks to n + # through the view's own #ensure_buffered loop, one parent fill per pass. + test "read(n) on a view still fills to n, which is Ruby's contract" do + subject = source("ab", "cd", "ef") + + assert_equal("abcd", subject.peek.read(4)) + assert_equal(2, subject.fills) + end + + test "read_exactly on a view still fills to the count" do + subject = source("ab", "cd", "ef") + + assert_equal("abcde", subject.peek.read_exactly(5)) + assert_equal(3, subject.fills) + end + end + # ---- IO-22, IO-23 (property), IO-24: the views' lifecycle ---------------------------------- class ViewLifecycle < DexpaceTestCase include Sources diff --git a/gems/dexpace-core/test/dexpace/io_test.rb b/gems/dexpace-core/test/dexpace/io_test.rb index 320cb81..d454463 100644 --- a/gems/dexpace-core/test/dexpace/io_test.rb +++ b/gems/dexpace-core/test/dexpace/io_test.rb @@ -37,14 +37,22 @@ class DexpaceIOTest < DexpaceTestCase # ::IO is silently false. This is why core writes `::IO` and respond_to?, never is_a?(IO), and # why Dexpace/QualifiedCoreConstant refuses the bare name. String evals, because a block's # constants resolve in the block's own lexical scope (verified on 3.2.11, 3.4.10 and 4.0.6), - # and only a string eval gives the receiver's. + # and only a string eval gives the receiver's. The eval returns a lambda and the test hands it + # the pipe end it owns and closes, so the probe leaks no descriptor of its own: a lambda keeps + # the constant scope it was created in, which is the receiver's under a string eval. test "a bare IO inside module Dexpace is Dexpace::IO, not ::IO" do + reader, writer = ::IO.pipe inside = Dexpace.module_eval("IO", __FILE__, __LINE__) + probe = Dexpace.module_eval("->(x) { x.is_a?(IO) }", __FILE__, __LINE__) assert_same(Dexpace::IO, inside) refute_same(::IO, inside) assert_same(::IO, Dexpace.module_eval("::IO", __FILE__, __LINE__)) - refute(Dexpace.module_eval("::IO.pipe.first.is_a?(IO)", __FILE__, __LINE__)) + assert_kind_of(::IO, reader) + refute(probe.call(reader)) + ensure + reader&.close + writer&.close end # The consumer half of the finding docs/first-release.md carries as a blocker: a consumer's own @@ -55,10 +63,11 @@ class DexpaceIOTest < DexpaceTestCase test "a consumer class that includes Dexpace sees Dexpace::IO for a bare IO" do consumer = Class.new { include Dexpace } reader, writer = ::IO.pipe + probe = consumer.class_eval("->(x) { x.is_a?(IO) }", __FILE__, __LINE__) assert_same(Dexpace::IO, consumer.class_eval("IO", __FILE__, __LINE__)) - refute(consumer.class_eval("::IO.pipe.first.is_a?(IO)", __FILE__, __LINE__)) assert_kind_of(::IO, reader) + refute(probe.call(reader)) ensure reader&.close writer&.close From 57aa9d882290c87b5580c5cb23e82b1ab58371c5 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Tue, 15 Sep 2026 20:57:08 +0300 Subject: [PATCH 7/8] docs: phase 3a record, the byte-streaming layer as built, and the pointers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checklist, docs/work/mvp/phase3/phase3a/2026-09-08-phase3a-io-contracts-checklist.md, written from the build: forty-two rows, 34 implemented and 8 not built (the retired provider apparatus, design §10.1), the guards run red with what each said, the Tasks 5-9 ladder, the audit groups re-checked, seventeen departures from the plan's text and the findings routed. The design's ledger gains P3-13 and an "As built" addendum: #clear_tap dropped, view invalidation cascading, the nil window, the untyped duck-checked parameters, the NFR-11 allowlist, and the string-eval shadow probe. docs/sdk-documentation/io.md is the as-built page, every example run against the code, opening with the Dexpace::IO shadow a consumer that includes Dexpace meets; architecture.md, quality-gates.md, the core README, README.md and docs/README.md point at it, and docs/first-release.md's include-Dexpace blocker narrows to its release-decision half. The roadmap gains the phase 3a status note, and CLAUDE.md's built-phases paragraph, claim sentences and the constraints-that-bite line on the seventh cop are rewritten from the tree. The consolidation of P3-1..P3-13 into design §10 is a human's. --- CLAUDE.md | 49 ++- README.md | 14 +- docs/README.md | 13 +- docs/first-release.md | 5 +- docs/sdk-documentation/architecture.md | 19 +- docs/sdk-documentation/io.md | 246 +++++++++++++ docs/sdk-documentation/quality-gates.md | 4 +- .../2026-09-05-ruby-sdk-v1-roadmap-design.md | 46 +++ ...26-09-08-phase3a-io-contracts-checklist.md | 328 ++++++++++++++++++ .../2026-09-08-phase3a-io-contracts-design.md | 61 ++++ gems/dexpace-core/README.md | 50 ++- 11 files changed, 785 insertions(+), 50 deletions(-) create mode 100644 docs/sdk-documentation/io.md create mode 100644 docs/work/mvp/phase3/phase3a/2026-09-08-phase3a-io-contracts-checklist.md diff --git a/CLAUDE.md b/CLAUDE.md index 178fd4a..48cf05c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,8 +13,8 @@ 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, 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`, +**Phases 0, 1, 2 and 3a are built; the domain model, the seam layer and the byte-streaming 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` @@ -25,8 +25,12 @@ provider registry `Dexpace::Registry`, the three seams `Dexpace::Transport`, `De 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 +(`docs/work/mvp/phase2/2026-09-07-phase2-seam-foundations-checklist.md`) — and the byte-streaming layer under +`Dexpace::IO`: the FIFO `Buffer`, `BufferedSource` and `BufferedSink` with the `TypedReads` and `TypedWrites` +vocabularies, `TeeSink`, `MAX_MATERIALIZED_BYTES`, the three RBS interfaces `_Source`/`_Sink`/`_Chunked`, and the +two failure types `Dexpace::StreamError < ::IOError` and `Dexpace::EndOfStreamError < ::EOFError` +(`docs/work/mvp/phase3/phase3a/2026-09-08-phase3a-io-contracts-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, @@ -356,12 +360,22 @@ 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). +- **Inside `module Dexpace`, anywhere, write `::Thread`, `::Queue`, `::Mutex`, `::SizedQueue`, + `::ConditionVariable`, `::JSON` and `::IO`** — 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; `Dexpace::IO` is defined by core itself, so a bare `IO` is that module from the first `require`, and + `x.is_a?(IO)` is silently `false` for a real `::IO` — inside a consumer's own `class C; include Dexpace` too. + The seventh custom cop, `Dexpace/QualifiedCoreConstant`, enforces it over every gem's `lib/` and skips a + constant's own definition site (`module Dexpace; module IO`); a stand-in `Dexpace::Async::Thread` in + `future_shadowing_test.rb` proves the adapter half behaviourally and `io_test.rb` the core half (phase 2's + design, §9 addendum A1; phase 3a's P3-7). Core never writes `is_a?(IO)`: every caller-supplied stream is + checked with `respond_to?`. +- **`IO-1`'s read primitive is `#read_into(dest, count:)`, and `#read`/`#readpartial`/`#getbyte`/`#each` keep + Ruby's semantics** — `IO.copy_stream`, which is what `Net::HTTP#body_stream=` uses, hands `#readpartial` one + buffer it reuses across every call and expects overwritten, so a tail-appending `#read` would corrupt every + streamed upload; `Dexpace::EndOfStreamError < ::EOFError` is load-bearing for the same reason, because + `copy_stream` terminates only on an `EOFError` subclass (phase 3a's P3-1, P3-2). The ingress retag is + `String#b`, never `force_encoding`, which raises on the frozen chunks a Rack body yields (phase 3a's design). - **`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 @@ -468,9 +482,10 @@ 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 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 + the phase-1 HTTP domain model, the phase-2 seam layer and the phase-3a byte-streaming layer — fifty-one + phase-1, phase-2 and phase-3a files under `lib/dexpace/` beside phase 0's `version.rb`, every one mirrored + in `sig/`, and every one of the fifty-one 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 @@ -478,10 +493,10 @@ probe compares each against the live tree, and a count written anywhere else in - 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/`, `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, + carry that phase's design, plan and checklist; `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/` + `phase3/phase3a/`, holding that sub-phase's design, plan and checklist, and `phase3/phase3b/`, holding its + design and plan — four checklists written so far, each at implementation; `phase4/` carries its segmentation design, `docs/work/mvp/phase4/2026-09-08-phase4-segmentation-design.md`, and three sub-phase directories — `phase4/phase4a/`, `phase4/phase4b/` and `phase4/phase4c/`; each holds a design @@ -550,5 +565,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, phase 1's and phase 2's is still to be written at execution time. + Every checklist but phase 0's, phase 1's, phase 2's and phase 3a'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 fadd598..df0c15e 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,14 @@ not compete with `faraday` or `httpx` on the easiest way to fetch a JSON endpoin ## Status -**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`) — 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 +**Phases 0, 1, 2 and 3a 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 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`) +— and the byte-streaming layer every body will stand on: the FIFO buffer, the buffered source and +sink with typed reads and non-consuming views, and the tee sink +(`docs/sdk-documentation/io.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 f74f36c..20096b8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -101,14 +101,15 @@ 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 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 +domain model, phase 2's seam layer and phase 3a's byte-streaming 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 four 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, [`sdk-documentation/http.md`](./sdk-documentation/http.md), -because the domain model is the thing phase 1 built, and +because the domain model is the thing phase 1 built, [`sdk-documentation/seams.md`](./sdk-documentation/seams.md), because the seam layer is the thing -phase 2 built. +phase 2 built, and [`sdk-documentation/io.md`](./sdk-documentation/io.md), because the +byte-streaming layer is the thing phase 3a built. ## Keeping this file true diff --git a/docs/first-release.md b/docs/first-release.md index 5c34dc8..bde6010 100644 --- a/docs/first-release.md +++ b/docs/first-release.md @@ -88,8 +88,9 @@ stated in the release notes rather than discovered at `bundle install`. `Dexpace/QualifiedCoreConstant` covers `gems/*/lib/**/*.rb` and stops at the gem boundary by construction. Renaming is not on the table — design §3.1 and §10.2 name `Dexpace::IO::Buffer`, and `P1-1` keeps a namespace the design gave a subsystem. Phase 3a states the hazard in `Dexpace::IO`'s own YARD - block; what is owed before the tag is the same warning in `docs/sdk-documentation/`, where a consumer - meets it, and a release decision that has seen it + block and, since 2026-09-15, in `docs/sdk-documentation/io.md`'s opening section and the core README, + where a consumer meets it, with the consumer case pinned in `gems/dexpace-core/test/dexpace/io_test.rb`; + what is still owed before the tag is a release decision that has seen it - [ ] **A decision on where a per-call or operation-level `AuthDescriptor` is carried.** `AUTH-4`–`AUTH-7`'s tier resolution takes a per-call, an operation and a client `AuthDescriptor` in that preference order, and `6c` ships the resolver as a correct, tested, stateless pure function. What no phase specifies — not diff --git a/docs/sdk-documentation/architecture.md b/docs/sdk-documentation/architecture.md index 9cc044b..789d4ea 100644 --- a/docs/sdk-documentation/architecture.md +++ b/docs/sdk-documentation/architecture.md @@ -1,12 +1,13 @@ # 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, 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). +skeletons and the gate set, phase 1 the HTTP domain model in `dexpace-core`, phase 2 the seam +layer in the same gem, and phase 3a the byte-streaming layer beneath every body; the body model, +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. Four pages are real already, because their +subjects are: [`quality-gates.md`](./quality-gates.md), [`http.md`](./http.md), +[`seams.md`](./seams.md) and [`io.md`](./io.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 @@ -25,6 +26,12 @@ core-owned future and cancellation token, the close contract, and how an `Operat `Request`. Written against the layer phase 2 shipped; derives from `docs/sdk-design-ruby/03-seam-by-seam-idiomatic-mapping.md`. +[io.md](./io.md) — the byte-streaming layer every body stands on: the FIFO buffer, the buffered +source and sink with their typed reads and non-consuming views, the tee sink, the one ownership +rule, and the `Dexpace::IO` shadow a consumer that includes `Dexpace` has to know about. Written +against the layer phase 3a shipped; derives from +`docs/sdk-design-ruby/03-seam-by-seam-idiomatic-mapping.md` §3.1. + 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`. diff --git a/docs/sdk-documentation/io.md b/docs/sdk-documentation/io.md new file mode 100644 index 0000000..4e6e378 --- /dev/null +++ b/docs/sdk-documentation/io.md @@ -0,0 +1,246 @@ +# The byte-streaming layer + +**As built by phase 3a, in `dexpace-core`, written against source on 2026-09-15.** This page says +what the streaming objects are and how they behave: one FIFO buffer, a buffered source and sink +with typed reads and non-consuming views, and a tee sink that mirrors a bounded tap. What each is +*required* to do is `docs/product-spec/05-i-o-contracts.md`; how the design maps it to Ruby is +`docs/sdk-design-ruby/03-seam-by-seam-idiomatic-mapping.md` §3.1; the per-requirement proof is +`docs/work/mvp/phase3/phase3a/2026-09-08-phase3a-io-contracts-checklist.md`. Signatures live in +`gems/dexpace-core/sig/dexpace/io/`, and this page does not restate them. Every example below was +run against the built code. There is no registry, no factory and no installation call here: the +provider seam the reference build had was retired (design §10.1), and the behavioural contract is +the whole deliverable. + +## Read this first: `Dexpace::IO` shadows `::IO` + +`Dexpace::IO` is the namespace for this layer, and inside `module Dexpace` a bare `IO` is that +module and not Ruby's. That is loud for a method call — `IO.pipe` raises `NoMethodError` — and +**silent for a type test**: `x.is_a?(IO)` is `false` for a real `::IO`, `IO === x` is `false`, and +a `case x when IO` falls through, with no error and no warning. Core never writes `is_a?(IO)` +(every stream it accepts is checked with `respond_to?`) and a RuboCop cop refuses the bare name +inside every gem's `lib/`. + +**The same is true inside your own class if it includes `Dexpace`.** `include` puts `Dexpace` +ahead of `Object` in the class's ancestry, so a bare `IO` there resolves to `Dexpace::IO` too: + +```ruby +class Uploader + include Dexpace # for the short names -- and this is the cost + + def stream?(x) = x.is_a?(IO) # silently false for a real ::IO +end +``` + +Write `::IO` in any class that includes `Dexpace`, or do not include it. A top-level +`include Dexpace` is unaffected, because `Object`'s own constants are searched first; `extend +Dexpace` and a class with no include are unaffected. The same holds for every flat `Dexpace::` +constant that shares a name with a core class — `Dexpace::Method` is the other one — and no gate +this repository owns can reach your file. + +## Two read primitives, and why they are two + +`IO-1`'s primitive is **`#read_into(dest, count:)`**: it appends up to `count` bytes to the tail +of a BINARY `dest` and returns how many — at least 1 when bytes remain, exactly 0 for a zero count +(never `-1`, even at end of stream), and `-1` when the source is exhausted before any byte. It is +deliberately not called `#read`, because `#read`, `#readpartial`, `#getbyte`, `#readbyte` and +`#each` carry **Ruby's** semantics and are what makes a `BufferedSource` a drop-in for `::IO`: +`IO.copy_stream` — which is what `Net::HTTP#body_stream=` uses — hands `#readpartial` one buffer +it reuses on every call and expects overwritten, so a tail-appending `#read` would corrupt every +streamed upload. Both are asserted against the same buffer in one test. + +```ruby +source = Dexpace::IO::BufferedSource.of_bytes("héllo wörld") +buf = +"seed".b + +source.read_into(buf, count: 3) # => 3; buf is "seedh\xC3\xA9" -- appended +source.read(4, buf) # => "llo "; buf is "llo " -- overwritten +source.readpartial(100) # => "wörld".b +source.readpartial(1) # raises Dexpace::EndOfStreamError, an ::EOFError +source.read(1) # => nil +source.read_into(+"".b, count: 1) # => -1 +source.read_into(+"".b, count: 0) # => 0 +``` + +`Dexpace::EndOfStreamError < ::EOFError` is load-bearing: `IO.copy_stream` terminates cleanly on +an `EOFError` subclass from a duck-typed `#readpartial` and propagates anything else. `#read` and +`#readpartial` hand `outbuf` back tagged `Encoding::BINARY` whatever tag it arrived with (`::IO` +and `StringIO` disagree with each other on this, and `StringIO` changed at Ruby 3.4). + +## The typed reads + +Every source responds to the whole vocabulary — `Dexpace::IO::TypedReads` supplies it over one +private hook, `#fill(min_bytes)`, which is also the whole of what a third-party source has to +implement (include the module and `Dexpace::Closeable`, define `#fill` and `#release`). Every +`String` that comes back is BINARY except the two explicit decodes. + +```ruby +source = Dexpace::IO::BufferedSource.of_bytes("one\r\ntwo\ncafé".b) +source.read_line_utf8 # => "one" -- "\n" and "\r\n" terminate and are consumed; a lone "\r" is content +source.read_line_utf8 # => "two" +source.read_utf8 # => "café", tagged UTF-8; #read_string(Encoding::ISO_8859_1) retags likewise +source.eof? # => true +``` + +`#read_exactly(count)` returns exactly `count` bytes or raises `Dexpace::EndOfStreamError` +consuming nothing; `#skip(count)` advances exactly that far or raises the same way, and `#skip(0)` +is always a no-op. `#read_utf8(count:)` and `#read_string(encoding, count:)` take a **byte** +count. The decodes only retag: an invalid sequence comes back as-is with `#valid_encoding?` +false, because the one place a replacement policy applies is `Response#body_string`, which phase 3b +owns. Every count and offset is a keyword; the four `::IO`-compatible bridge methods keep Ruby's +positional signatures because call-compatibility with `::IO` is their entire purpose. + +## Sources: three factories, one ownership rule + +| Factory | Wraps | Owns it? | +|---|---|---| +| `BufferedSource.wrapping(io)` | anything responding to `#readpartial` or `#read` — a `StringIO`, an `IO.pipe` end, a `Tempfile`, your own object | **yes**: closing the source closes `io` | +| `BufferedSource.of_bytes(string)` | an independent copy of the `String` | no external resource | +| `BufferedSource.over(body)` | anything responding to `#each` yielding `String` chunks — a Rack body, an `Array`, an `Enumerator` | **no**: closing the source never closes the body | + +`.wrapping` takes ownership because `IO-6` requires it and there is deliberately no borrowing +variant; `.new` is private so ownership cannot be set through an argument. With a block it +closes on any exit path and returns the block's value: + +```ruby +io = StringIO.new("abc") +Dexpace::IO::BufferedSource.wrapping(io) { |source| source.read_exactly(2) } # => "ab" +io.closed? # => true +``` + +`.over` pulls on demand — no read-ahead accumulates — and preserves the body's own chunk boundaries +through `#each`, so a chunk of `""` means "no bytes this time" and never end of stream. What it +does not do is close the body: its callers are always downstream of something that already owns +the response, and if the body holds a resource, whoever created it closes it. A source closed +before exhaustion abandons the body's enumerator with its `ensure` unrun; that is Ruby's +`Enumerator` and nothing here papers over it. `#owns_upstream?` reads the ownership fact back; +`#view?` says whether a source is a view. + +Chunks arriving from a body are stored as they are when frozen and already BINARY (the ordinary +Rack shape, no copy) and as `chunk.b` otherwise — never `force_encoding`, which raises on a frozen +`String`. Every source is itself a canonical body representation: it responds to `#each` yielding +BINARY chunks, so it can be handed to anything that consumes one. + +## Views: `#peek` and `#slice` + +A view is a `BufferedSource` over another source that reads without advancing its parent's +cursor. `#peek` covers the whole remaining source; `#slice(offset:, count:)` covers at most `count` +bytes starting `offset` ahead of the cursor. Both drive the parent's fill as needed, so a view can +read bytes the parent has not pulled from its upstream yet. + +```ruby +source = Dexpace::IO::BufferedSource.of_bytes("abcdef") +source.peek.read # => "abcdef" +source.read_exactly(2) # => "ab" -- the peek consumed nothing +slice = source.slice(offset: 1, count: 2) +slice.read # => "de" +source.read # => "cdef" +slice.read # raises Dexpace::ClosedError: the window is "behind" the cursor +``` + +The rules, all of them `IO-19`–`IO-24`'s except the last, which the specification leaves open and +this port fixes: + +- Views are independent of each other; a slice of a slice composes offsets additively and is + capped at the outer window's remaining bytes. +- An offset past the end constructs fine and reads as empty; a negative offset or count is refused + at construction. +- Closing a view closes neither its parent nor the parent's cursor. Closing the parent invalidates + every view derived from it, transitively, so every later read on one raises + `Dexpace::ClosedError` — a state error, never an EOF. +- **The parent holds nothing back for a view.** Its retention floor is its own cursor: a view + reading ahead of the parent retains what it reads ahead, and a view whose next byte the parent + has already consumed fails loudly rather than serving bytes from somewhere else. Bytes a view + has already read are behind it and the parent may pass them freely. + +## The buffer + +`Dexpace::IO::Buffer` is a FIFO that is simultaneously a source and a sink: bytes written through +`#write` read back in order through every typed read, and `#bytesize` is what is held now. + +```ruby +buffer = Dexpace::IO::Buffer.new +buffer.write("ab", "cd") # => 4 +buffer.snapshot # => "abcd" -- a fresh, unfrozen, independent copy +buffer.read_exactly(1) # => "a" +other = Dexpace::IO::Buffer.new +buffer.copy_to(other, offset: 1) # copies "cd" from the cursor + 1 through the end; consumes nothing +buffer.close +buffer.write("x"); buffer.snapshot # => "bcdx" -- an in-memory close frees nothing +``` + +The close asymmetry is `IO-42`'s and is deliberate in both directions: a `Buffer` stays readable +and writable on its own surface after `#close`, so a logging snapshot taken after the wrapper +closed still works, while its close still invalidates every view derived from it. A stream-backed +source or sink refuses every read, write, `#emit` and `#flush` after close with +`Dexpace::ClosedError`. + +`#snapshot`, `#read` with no count, `#read_utf8`, `#read_string`, `#read_exactly` and a bounded +view's read all refuse to build one `String` over `Dexpace::IO::MAX_MATERIALIZED_BYTES` (64 MiB, +a constant nothing takes as a keyword until phase 5 wires configuration) with a +`Dexpace::StreamError` naming the streaming alternatives, before allocating anything. +`#read(length)`, `#readpartial(maxlen)` and `#read_line_utf8` are outside the guard: the first two +take a length the calling code chose, and a line has no length until its terminator arrives, so +its bound belongs to the caller (phase 7's SSE machine carries its own). + +## Sinks and the tee + +`Dexpace::IO::TypedWrites` is the write vocabulary, over one private hook, `#deliver(string)`, +which always receives a frozen BINARY `String`: `#write(*strings)` (the `::IO`-compatible bridge, +returning the byte count), `#write_from(buffer, count:)` (exactly `count` bytes off the head of a +`Buffer`, or a `Dexpace::StreamError` and nothing consumed), `#write_all(source)` (pumps anything +responding to `#read_into` to exhaustion and returns the total; a read of 0 for a positive count +is a source-contract violation, never tolerated as EOF), `#write_utf8(string, range: nil)` (a +character range), `#write_string(string, encoding:)`, and `#emit`/`#flush`. `#emit` pushes one +level and never calls the underlying `#flush`; `#flush` does both. + +`BufferedSink.wrapping(io)` owns `io` exactly as the source factory does and pushes every write +straight through, so a failed underlying write leaves nothing staged to prepend to the next; an +underlying `#write` that reports fewer bytes than it was handed is a contract violation. +`TeeSink.new(primary:, tap_limit: nil)` mirrors every write into a tap **before** forwarding the +full payload to the primary, stops mirroring at `tap_limit` bytes while still forwarding +everything, and forwards `#emit`, `#flush` and `#close` to the primary only, leaving the tap +readable through `#tap_snapshot` afterwards: + +```ruby +out = StringIO.new(+"".b) +sink = Dexpace::IO::BufferedSink.wrapping(out) +tee = Dexpace::IO::TeeSink.new(primary: sink, tap_limit: 4) +tee.write_all(Dexpace::IO::BufferedSource.of_bytes("héllo")) +tee.close +out.string # => "héllo".b -- the wire body, untruncated; out is closed +tee.tap_snapshot # => "h\xC3\xA9l".b -- the first four bytes +tee.buffer # raises Dexpace::StreamError naming IO-28 and the typed write methods +``` + +A tee has no `#clear_tap`: it binds its primary at construction, so one tee cannot span two +attempts, and the request-logging wrapper phase 3b builds makes a fresh tee per write. A primary's +own failure propagates once, as the identical object; the tee's staging buffer is cleared in an +`ensure`, so nothing is swallowed or duplicated. + +## Errors, and what is deliberately not one + +| Class | Ancestry | When | +|---|---|---| +| `Dexpace::InvalidArgumentError` | `::ArgumentError` | a negative or non-`Integer` count, a bad window, a frozen or non-BINARY `#read_into` destination, an unknown charset — before any I/O | +| `Dexpace::EndOfStreamError` | `::EOFError` | `#read_exactly`, `#readbyte`, `#readpartial`, `#skip` at end of stream | +| `Dexpace::StreamError` | `::IOError` | a short `#write_from`, a zero read for a positive count, a short underlying write, a materialisation over the ceiling, `TeeSink#buffer` | +| `Dexpace::ClosedError` | `::StandardError` | any use of a stream-backed source, sink or tee after close, and any read of an invalidated or closed view | + +`Dexpace::StreamError` is in Ruby's `IOError` family because the requirements say "an I/O error", +and it is a sibling of the transport error phase 8 adds, never a subclass: a stream-contract +violation must not claim to be retryable. `Dexpace::IOError` and `Dexpace::EOFError` are never +defined, for the same reason `Dexpace::ArgumentError` is not — they would shadow Ruby's inside +`module Dexpace`. `StreamError.short_transfer(transferred:, expected:)` and +`.zero_read(requested:)` are the one place the two message forms come from, so the body layer's +copies cannot diverge. + +## Threads, fibers and clocks + +Every streaming instance is a single-threaded contract: callers serialise access, and independent +views may be used from different threads. The one cross-thread guarantee is close: the close latch +is written and read under a `Thread::Mutex`, acquired once per public call and never held across a +fill, a read or a drain, so a close on one thread reliably invalidates a view being read on another +— and no lock is ever held across a suspension point, so two fibers of one thread may interleave +reads on one source. Nothing here owns a clock: no method takes a timeout, a deadline or a +cancellation token, and the only blocking call is the wrapped stream's own read, which the +transport that owns the socket bounds. diff --git a/docs/sdk-documentation/quality-gates.md b/docs/sdk-documentation/quality-gates.md index 8c9a828..cb5a98e 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 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 | +| 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`/`IO` anywhere inside `module Dexpace` in every gem's `lib/` (phase 3a widened it from two namespaces and one gem, and taught it that `module Dexpace; module IO` declares the shadow rather than referring to Ruby's) | 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`: 63 rejected and 37 accepted sources, the seventh cop's in two nested classes of their own — phase 2's ten and seven, phase 3a's eight and ten | | 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/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 2c7bab3..b7c8236 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 @@ -2692,3 +2692,49 @@ its checklist, the third written; the surface manifest is 318 lines, and the fou 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. + +**2026-09-15** — **Phase 3a implemented**, as three stacked branches against issue #11: code, tests, +documentation. `dexpace-core` now carries the byte-streaming layer beside phase 1's domain model and phase 2's +seam layer — nine new `lib/` files under `lib/dexpace/`, exactly the design's Module Layout: two flat failure +types, `Dexpace::StreamError < ::IOError` and `Dexpace::EndOfStreamError < ::EOFError`, and seven under +`Dexpace::IO` — the namespace with `MAX_MATERIALIZED_BYTES`, the `TypedReads` and `TypedWrites` vocabularies, +`BufferedSource`, `Buffer`, `BufferedSink` and `TeeSink` — each with its `sig/` mirror declaring every method +and its `test/` mirror, plus three fakes (`fake_chunked.rb`, `fake_source.rb`, `fake_sink.rb`); two files +changed as the design said (`Closeable#closed?` reads the latch under the mutex, P3-6; the entry file's nine +`require_relative`s), the seventh cop gained `IO`, the one-segment `Dexpace` watch and a definition-site guard +over every gem's `lib/` (P3-7), 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/phase3/phase3a/2026-09-08-phase3a-io-contracts-checklist.md`: +forty-two rows, 34 ✅ and 8 🚫 (`IO-30`–`IO-36`, `IO-39`, the retired provider apparatus citing design +§10.1, with `IO-30`'s independent-copy clause kept as a property of `.of_bytes`), nothing ⏳, nothing N/A. +`bundle exec rake` is green on 4.0.6 at the tests tip with 99.95% line coverage against the 80% floor and +774 runs across the six gems; the matrix set is green on 3.2.11, 3.3.12 and 3.4.10; the core suite is green +under three further seeds; the code tip is red on the SimpleCov floor alone (78.00%, 0 failures). **Every guard the +plan asks to be run red was run red** — P3-6's fiber-held-mutex assertion against phase 2's reader on all +three interpreters (`ThreadError expected but nothing was raised`), the cop's six new rejections before the +widening, the `fill(1)` amendment's count guards (`Expected: 4096 Actual: 1`), and the Tasks 5–9 ladder +against the finished suite (12/57, 4/47, 4/38, 1/20, 1/0, then 0/0 failures/errors). **One correctness defect +the plan's fences carried was found and closed by a test the plan did not have**: view invalidation did not +cascade, so a slice of a slice survived its root's close and could pull fresh bytes through an invalidated +outer view from a closed `.of_bytes` root; `#dexpace_invalidate` now releases a view's own views and the +parent-side entry points check readability first (checklist deviation 3). **Two decisions the plan left open +were taken in the open**: `TeeSink#clear_tap` is dropped — 3b builds a fresh tee per write, so the method had +no caller in core while being `NFR-4`-locked surface (deviation 1) — and `BufferedSource.__dexpace_view` is a +declared public singleton method because strict Steep refuses an undeclared `def self.` (deviation 7). +Seventeen departures from the plan's text are itemised in the checklist, none lowering a gate; one widens +`tools/rbs_surface.rb`'s `NFR-11` allowlist by `EOFError`, `IOError` and `Encoding` — the false positive phase +1 fixed for `Data`, `ArgumentError` and `StringScanner`, fixed the same way with a fixture and a pinning gate +test (deviation 6) — and one moves phase 2's accepted cop row `module Dexpace; module Transport; Thread.new` +to rejected, a tightening the one-segment watch implies (deviation 2). The unbounded window is `nil` rather +than `Float::INFINITY` and three RBS interface-typed parameters are `untyped`, because strict Steep refused +the plan's shapes (deviations 4 and 5). The plan's RuboCop-baseline finding does not reproduce against the +built tree — 200 files, no offenses with `--ignore-parent-exclusion` — so phase 0's plan, Task 3 gains +nothing. The design's ledger gains P3-13 and an "As built" addendum; the one postponed item keeps its owner +(`IO-38` on a GVL-free Ruby, `docs/first-release.md` § Post-release triggers), the `include Dexpace` blocker's +documentation half is written in `docs/sdk-documentation/io.md` and the core README with the consumer case +pinned in `io_test.rb`, and its release-decision half stays open. The counts that changed: `gems/` is still +six; `dexpace-core`'s `lib/dexpace/` is fifty-one phase-1, phase-2 and phase-3a files beside phase 0's +`version.rb`; `phase3/phase3a/` now carries its checklist, the fourth written; the surface manifest is 367 +lines. `CLAUDE.md`'s built-phases paragraph, its gem and phase-directory sentences and the constraints-that-bite +line on the seventh cop are rewritten from what was built. The consolidation of P3-1–P3-13 into design §10 is a +human's, as it was for phases 1 and 2: `docs/sdk-design-ruby/` is frozen, and `docs/deviations.md` is left as +phase 2 left it for phase 10 to flip. diff --git a/docs/work/mvp/phase3/phase3a/2026-09-08-phase3a-io-contracts-checklist.md b/docs/work/mvp/phase3/phase3a/2026-09-08-phase3a-io-contracts-checklist.md new file mode 100644 index 0000000..35eb14a --- /dev/null +++ b/docs/work/mvp/phase3/phase3a/2026-09-08-phase3a-io-contracts-checklist.md @@ -0,0 +1,328 @@ +# Phase 3a — I/O Contracts: 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/phase3/phase3a/2026-09-08-phase3a-io-contracts.md`. Task numbers are that +plan's. Design: `docs/work/mvp/phase3/phase3a/2026-09-08-phase3a-io-contracts-design.md`, whose +Deviation Ledger rows `P3-n` are cited below; the charter is +`docs/work/mvp/phase3/2026-09-08-phase3-segmentation-design.md`. 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 + +Forty-two: `IO-1`–`IO-42`. + +| ID | Level | Status | Task(s) | What was built, and where it is proven | +|---|---|---|---|---| +| `IO-1` | MUST | ✅ | 5 | `Dexpace::IO::TypedReads#read_into(dest, count:)` — the primitive, deliberately not `#read` (P3-1): it appends to the tail of a BINARY `dest`, returns at least 1 when `count` is positive and the source is not exhausted, exactly 0 for a zero count, `-1` when exhausted before any byte, never more than `count`, and fills at most once before serving. The count the caller asked for is the count the fill hook receives, so a wrapped stream is asked for `count` bytes and not for one (the plan's 2026-09-13 amendment; guard run red below). Asserted against `#read` on the same non-empty buffer in one test, so a later merge of the two turns red (`dexpace/io/typed_reads_test.rb`, the primitive rows and `Bridge`) | +| `IO-2` | MUST | ✅ | 5 | A zero count returns 0 and never `-1`, on an exhausted source too, and touches neither the buffer nor the fill hook. The check sits after `IO-42`'s close check, so a closed stream-backed source refuses a zero-count read as it refuses every other (plan decision 5) (`dexpace/io/typed_reads_test.rb`; `dexpace/io/buffered_source_test.rb`, `Close`) | +| `IO-3` | MUST | ✅ | 5, 6, 9, 11, 12 | A negative or non-`Integer` count, offset, length or maxlen is `Dexpace::InvalidArgumentError` naming the argument and the value, before any I/O and with no partial side effect — asserted for `#read_into`, `#read`, `#readpartial`, `#read_exactly`, `#skip`, `#read_string`, `#slice`, `#write_from` and `#copy_to`; `#read_into` also refuses a non-`String`, frozen or non-BINARY destination the same way (`dexpace/io/typed_reads_test.rb`, `Rejections`; `dexpace/io/typed_writes_test.rb`, `WriteFrom`; `dexpace/io/buffer_test.rb`, `CopyTo`) | +| `IO-4` | MUST | ✅ | 11, 13, 14 | `TypedWrites#write_from(buffer, count:)` removes exactly `count` bytes from the head of a `Dexpace::IO::Buffer` and pushes them; a buffer holding fewer raises `Dexpace::StreamError.short_transfer` naming both counts and consumes nothing; a zero count is a no-op; a non-`Buffer` is refused. Re-tested against a real `Buffer` into a `BufferedSink` over a `StringIO` and through a `TeeSink` (`dexpace/io/typed_writes_test.rb`, `WriteFrom`; `dexpace/io/buffered_sink_test.rb`; `dexpace/io/tee_sink_test.rb`, `Vocabulary`) | +| `IO-5` | MUST | ✅ | 11, 12, 13, 14 | `#flush` on every sink and `#close` on every source, sink, buffer and tee (`Dexpace::Closeable`). `BufferedSink#flush` pushes what is staged and calls the underlying `#flush` when there is one; `TeeSink#flush` forwards to the primary only (`dexpace/io/buffered_sink_test.rb`, `Staging`; `dexpace/io/tee_sink_test.rb`, `Lifecycle`) | +| `IO-6` | MUST | ✅ | 10, 13 | Read out of appendix C, its only normative home, and cited with design §10.12 — never `SEAM-3` (R3; `docs/knowledge/notes/message-bodies.md`). `BufferedSource.wrapping(io)` and `BufferedSink.wrapping(io)` take ownership: closing the wrapper closes the wrapped stream, `#owns_upstream?` reads it back, there is no borrowing variant (P3-12) and `.new` is private so ownership cannot be set through an unnamed argument (P3-11). `.of_bytes` and `Buffer.new` own no external resource; `.over` is the one stated exception and never closes what it wrapped. The bridge clause is asserted rather than inferred: `IO.copy_stream` through a wrapped pipe end, then `#close`, closes the pipe (`dexpace/io/buffered_source_test.rb`, the ownership rows and `Bridge`; `dexpace/io/buffered_sink_test.rb`) | +| `IO-7` | MUST | ✅ | 12 | `Dexpace::IO::Buffer` includes both vocabularies: bytes written through the sink surface read back through the source surface in order, `#bytesize` is what is currently held, and the mandatory property test writes N random BINARY chunks and reads them back, with a second property interleaving writes and reads (`dexpace/io/buffer_test.rb`) | +| `IO-8` | MUST | ✅ | 12, 14 | `Buffer#snapshot` is a fresh, independent, **unfrozen** BINARY copy from the cursor that neither consumes nor mutates the buffer; a later write does not reach it and mutating it does not reach the buffer (P3-10). `TeeSink#tap_snapshot` is the same over the tap (`dexpace/io/buffer_test.rb`, `Snapshot`; `dexpace/io/tee_sink_test.rb`) | +| `IO-9` | SHOULD | ✅ | 6, 12 | `Dexpace::IO::MAX_MATERIALIZED_BYTES`, 64 MiB, §10.18's substituted constant at §3.1's default, read directly and never from a keyword (asserted). One guard, `TypedReads#guard_materialization!`, at six sites (P3-4): `#read_exactly`, `#read_string`/`#read_utf8` with a count, the three count-less drains checked incrementally as the result grows (`#read` with no length, `#read_utf8`, `#read_string`), a length-bounded view's read through its known remaining window, and `Buffer#snapshot`. Every refusal is a `Dexpace::StreamError` naming the constant and the streaming alternatives (`#read_into`, `#each`, `#slice`, `Buffer#copy_to`) and allocates nothing; an over-ceiling slice constructs and refuses on the read (`IO-21`'s ordering); the one allocating test builds a buffer one byte over the ceiling and runs on every matrix row. `#read(length)`, `#readpartial(maxlen)` and `#read_line_utf8` are outside the guard on purpose, and P3-4 says why (`dexpace/io/typed_reads_test.rb`, `Ceiling`; `dexpace/io/buffer_test.rb`, `Snapshot`) | +| `IO-10` | MUST | ✅ | 12 | `Buffer#clear` discards every byte and the buffer accepts new writes afterwards; `Buffer#copy_to(other, offset: 0, count: nil)` copies a window measured from the cursor into another `Buffer` without consuming or mutating the source, defaults to "from offset through end", and refuses a negative offset or count, a window past the end and a non-`Buffer` target with `Dexpace::InvalidArgumentError` leaving both buffers untouched — never a ceiling error, because it materialises nothing (`dexpace/io/buffer_test.rb`, `CopyTo`) | +| `IO-11` | MUST | ✅ | 6, 8 | `#eof?` is true exactly when no bytes remain (and may fill once to find out); `#readbyte` returns 0..255 or raises `Dexpace::EndOfStreamError` and `#getbyte` returns 0..255 or `nil`; `#read` with no length drains everything and returns `""` when exhausted (`dexpace/io/typed_reads_test.rb`, `Typed`) | +| `IO-12` | MUST | ✅ | 6 | `#read_exactly(count)` returns exactly `count` BINARY bytes across chunk boundaries or raises `Dexpace::EndOfStreamError` consuming nothing; `#read_exactly(0)` is an empty BINARY `String`; `#read_utf8(count:)` and `#read_string(encoding, count:)` take a **byte** count and raise rather than return short (`dexpace/io/typed_reads_test.rb`, `Typed` and `Charsets`) | +| `IO-13` | MUST | ✅ | 6, 11 | Read side: `#read_utf8` and `#read_string(encoding)` retag the drained BINARY bytes with the named `Encoding` (or name), validate nothing — `#valid_encoding?` is the caller's question and `HTTP-42`'s replacement policy is 3b's — and refuse an unknown name before touching the stream. Write side: `#write_utf8(string, range: nil)` with a character range, and `#write_string(string, encoding:)`, which encodes into the named charset and refuses text it cannot represent. Every fixture is non-ASCII (`dexpace/io/typed_reads_test.rb`, `Charsets`; `dexpace/io/typed_writes_test.rb`, `Charsets`) | +| `IO-14` | MUST | ✅ | 7 | `#read_line_utf8`, hand-implemented over the chunk store and never `#gets`: `"\n"` and `"\r\n"` terminate and are consumed, a lone `"\r"` is content, a final unterminated line comes back as-is, `nil` when exhausted before any byte, a terminator split across two chunks is one terminator, the result is UTF-8-tagged. A 48-sample property over a pool including `"a\rb"`, `"é"` and `"hé\rllo"` mixes both terminators, an optional missing final one, and random chunk splits (`dexpace/io/typed_reads_test.rb`, `Lines`) | +| `IO-15` | MUST | ✅ | 6 | `#skip(count)` advances past exactly `count` bytes across chunk boundaries, raises `Dexpace::EndOfStreamError` consuming nothing when fewer remain, and `#skip(0)` is a no-op at and after EOF (`dexpace/io/typed_reads_test.rb`, `Typed`) | +| `IO-16` | SHOULD | ✅ | 8, 10, 11, 13 | The bridge is the source (R4): `#read(length = nil, outbuf = nil)`, `#readpartial(maxlen, outbuf = nil)`, `#getbyte`, `#readbyte` and `#each` carry Ruby's own semantics, end of stream the host-native way — `nil` from `#read`/`#getbyte`, `Dexpace::EndOfStreamError < ::EOFError` from `#readpartial`/`#readbyte` (P3-2) — and `outbuf` comes back BINARY whatever it arrived as (P3-13). Proved rather than restated: `IO.copy_stream` over a `BufferedSource.wrapping(pipe_end)` delivers the payload intact, terminates on the `EOFError` subclass, and closing the source afterwards closes the pipe; `#read(2, buf)` overwrites where `#read_into(buf, count: 2)` appends, in one test. The writable half: `TypedWrites#write(*strings)` returns the byte count, and `IO.copy_stream` writes into a `BufferedSink.wrapping(io)` and into a `TeeSink` (`dexpace/io/typed_reads_test.rb`, `Bridge`; `dexpace/io/buffered_source_test.rb`, `Bridge`; `dexpace/error/end_of_stream_error_test.rb`; `dexpace/io/buffered_sink_test.rb`; `dexpace/io/tee_sink_test.rb`, `Vocabulary`) | +| `IO-17` | MUST | ✅ | 11, 13 | `TypedWrites#write_all(source)` pumps anything responding to `#read_into` through 64 KiB reads, terminates only on `-1`, returns the total, and raises `Dexpace::StreamError.zero_read` on a read of 0 for a positive count — unconditionally, because §10.1 retired the adapter and "adapter-native" has no subject here — without spinning; a source's own failure propagates unchanged. `FakeSource` scripts the violation no real stream produces. The symmetric write-side rule: an underlying `#write` that reports fewer bytes than it was handed is `Dexpace::StreamError.short_transfer` (`dexpace/io/typed_writes_test.rb`, `Pump`; `dexpace/io/buffered_sink_test.rb`, `Staging`) | +| `IO-18` | SHOULD | ✅ | 11, 12, 13, 14 | `#emit` pushes one level and never calls the underlying `#flush`; `#flush` does both. `BufferedSink` stages and pushes in the same `#deliver`, so the staging buffer is empty between calls (plan decision 4); `Buffer`'s two are the no-ops the requirement permits, returning `self`; `TeeSink`'s forward to the primary only (`dexpace/io/buffered_sink_test.rb`, `Staging`; `dexpace/io/buffer_test.rb`; `dexpace/io/tee_sink_test.rb`, `Lifecycle`) | +| `IO-19` | MUST | ✅ | 9, 10 | `#peek` returns a `BufferedSource` in view mode over the whole remaining source — an unbounded window, never "whatever happens to be buffered" — that drives the parent's fill without advancing the parent's cursor (`dexpace/io/typed_reads_test.rb`, `Views`; `dexpace/io/buffer_test.rb`, `Close`; `dexpace/io/buffered_source_test.rb`, `Counts`) | +| `IO-20` | MUST | ✅ | 9, 10 | `#slice(offset:, count:)` exposes at most `count` bytes starting `offset` ahead of the cursor, never advances the parent, and reading past the window behaves as end of window for every read form (`dexpace/io/typed_reads_test.rb`, `Views`) | +| `IO-21` | MUST | ✅ | 9, 10 | An offset past the end constructs successfully and surfaces on first read as empty/EOF; a negative or non-`Integer` offset or count is refused at construction (`dexpace/io/typed_reads_test.rb`, `Views` and `Ceiling`) | +| `IO-22` | MUST | ✅ | 5, 9, 10, 12 | Closing a view closes neither its parent nor the parent's cursor and releases its registration; closing the parent — a `BufferedSource` or a `Buffer` — invalidates every outstanding view so that every later read on one raises `Dexpace::ClosedError`, **transitively through a view's own views**, and the two parent-side entry points a view drives (`#dexpace_ensure_buffered`, `#dexpace_window_copy`) check the parent's readability first, so a closed or invalidated intermediate serves nothing (deviation 3 below). P3-5's retention rule: a parent read past a live view's pin makes that view fail loudly naming "behind", never serving other bytes; a view that already pulled its window is unaffected (`dexpace/io/typed_reads_test.rb`, `ViewLifecycle`; `dexpace/io/buffer_test.rb`, `Close`) | +| `IO-23` | MUST | ✅ | 9, 10 | Two slices of one source have independent cursors and budgets; a slice of a slice composes offsets additively from the outer view's cursor and is capped at the outer window's remaining bytes, over a partially read outer view too, and a 48-sample property pins it over random offsets and budgets. Reading the outer view past the inner view's pin invalidates the inner one (P3-5 applied recursively) (`dexpace/io/typed_reads_test.rb`, `Views` and `ViewLifecycle`) | +| `IO-24` | MUST | ✅ | 9, 10 | Every read form on an explicitly closed view — thirteen of them, `#peek` and `#slice` included — raises `Dexpace::ClosedError`, a `StandardError` that is neither an `EOFError` nor a `StreamError` (P3-3) (`dexpace/io/typed_reads_test.rb`, `ViewLifecycle`; `dexpace/io/buffered_source_test.rb`, `Close`) | +| `IO-25` | MUST | ✅ | 14 | `Dexpace::IO::TeeSink.new(primary:)` mirrors every write into a `Buffer` tap and forwards the full payload to the primary, whose writes are byte-for-byte identical with or without a tap limit; a 48-sample property asserts tap and primary agree over random chunk sequences (`dexpace/io/tee_sink_test.rb`) | +| `IO-26` | MUST | ✅ | 14 | `tap_limit:` bounds the tap: it stops copying at the limit, a mid-write limit truncates exactly there, the full payload still reaches the primary, the default (`nil`, with `Float::INFINITY` accepted as the other spelling) is unbounded, a limit of 0 mirrors nothing and forwards everything, and a negative, non-`Integer` or finite-`Float` limit is refused at construction; a 32-sample property pins "exactly the first `limit` bytes" (`dexpace/io/tee_sink_test.rb`, the limit rows and `Vocabulary`) | +| `IO-27` | MUST | ✅ | 14 | The attempted bytes are mirrored before the primary is written, so a primary that raises mid-stream still leaves them in the tap; the staging buffer is cleared in an `ensure`, so a later write prepends nothing (`dexpace/io/tee_sink_test.rb`, `Failure`) | +| `IO-28` | MUST | ✅ | 14 | `TeeSink#buffer` is defined and raises `Dexpace::StreamError` naming `IO-28` and the typed write methods; no other route to the tap, the primary or the staging buffer is exposed, and the tap is reachable only through `#tap_snapshot`'s fresh copy. Design §10.10's honest position stands: `instance_variable_get` reaches anything and no fake proof is built (`dexpace/io/tee_sink_test.rb`, `Lifecycle`) | +| `IO-29` | MUST | ✅ | 14 | `#emit`, `#flush` and `#close` forward to the primary only, in that order when called in that order, tolerate a primary lacking any of the three, and leave the tap intact for snapshotting after close (`dexpace/io/tee_sink_test.rb`, `Lifecycle`) | +| `IO-30` | MUST | 🚫 | 10 | The provider seam's factory operations are retired with the seam (design §10.1; §12's `IO` row). **The one behavioural clause survives as a property of `.of_bytes`**: the source is an independent copy of the caller's `String`, so a later mutation of either reaches neither (`dexpace/io/buffered_source_test.rb`, the ownership rows) | +| `IO-31` | MUST | 🚫 | — | Provider resolution rules for a seam that does not exist (design §10.1). No fourth registry, no factory, no installation call, no discovery — asserted negatively by `Dexpace::IO.constants` being exactly the six streaming constants plus the ceiling (`dexpace/io_test.rb`) | +| `IO-32` | MUST | 🚫 | — | Install idempotence for the retired seam (design §10.1; one of the roadmap's five gap IDs, appendix-C only, read for nothing) | +| `IO-33` | MUST | 🚫 | — | Install-wins-after-discovery for the retired seam (design §10.1; appendix-C only) | +| `IO-34` | MUST | 🚫 | — | Discovery caching for the retired seam (design §10.1; appendix-C only) | +| `IO-35` | SHOULD | 🚫 | — | The late-replacement warning for the retired seam (design §10.1; appendix-C only) | +| `IO-36` | MUST | 🚫 | — | The provider's own conformance obligations for the retired seam (design §10.1) | +| `IO-37` | MUST | ✅ | 10 | No instance carries a lock over its read or write path; the only synchronised state is `Closeable`'s latch, read once per public entry point. The proof is the lock's absence: two fibers of one thread interleave `#read_exactly` calls on one `BufferedSource` whose upstream yields mid-read, and no `ThreadError` is raised — which a mutex held across a fill would raise (verified fact 7) (`dexpace/io/buffered_source_test.rb`, `Threads`) | +| `IO-38` | MUST | ✅ | 2, 10 | `Closeable#closed?` now reads the latch under the close mutex (P3-6), the one change to a phase-2 constant; the assertion that distinguishes it from phase 2's unsynchronised reader — a fiber holding the latch mutex across a `Fiber.yield`, a second fiber calling `#closed?`, `ThreadError` — was run red against phase 2's version first (guards below). Behaviourally: a `#read_exactly` blocked on a pipe on thread B fails with Ruby's own `IOError` when thread A closes the source, forwarded unchanged (`IO-40`), and every later read is `Dexpace::ClosedError`; a view being read on thread B raises `Dexpace::ClosedError` after thread A closes the parent — both sequenced through `Thread::Queue`s, never raced. On every CRuby row the GVL would hide a missing lock, which is the postponement the design records and `docs/first-release.md` § Post-release triggers owns (`dexpace/closeable_test.rb`; `dexpace/io/buffered_source_test.rb`, `Threads`) | +| `IO-39` | SHOULD | 🚫 | — | Lock-free reads of a provider registry that does not exist (design §10.1). The property survives in phase 2's `Dexpace::Registry`, as its checklist's `SEAM-9` row records | +| `IO-40` | MUST | ✅ | 10, 13, 14 | By construction: no method in 3a takes a timeout, a deadline or a `Cancellation`, and the only blocking call is the upstream's own `#readpartial`/`#read` inside `#fill`. The mirroring clause: `TeeSink` and `BufferedSink` clear their staging buffer in an `ensure`, never a `rescue`, so the primary's failure propagates once as the identical object — asserted with `assert_same` — and a blocked read's `IOError` reaches the caller unchanged (`dexpace/io/tee_sink_test.rb`, `Failure`; `dexpace/io/buffered_sink_test.rb`, `Staging`; `dexpace/io/buffered_source_test.rb`, `Threads`). The styleguide's per-call timeout rules are answered by `docs/knowledge/notes/resource-management.md` | +| `IO-41` | MUST | ✅ | 10, 12, 13, 14 | Close is idempotent on every source, buffer, sink and tee, the underlying resource is closed at most once — including when two threads close one source at once — and an upstream or primary with no `#close` is fine (`dexpace/io/buffered_source_test.rb`, `Close` and `Threads`; `dexpace/io/buffer_test.rb`, `Close`; `dexpace/io/buffered_sink_test.rb`, `Close`; `dexpace/io/tee_sink_test.rb`, `Lifecycle`) | +| `IO-42` | MUST | ✅ | 10, 12, 13, 14 | Both directions, two tests that fail in opposite ways. A stream-backed `BufferedSource` raises `Dexpace::ClosedError` from every read form after close (a zero-count `#read_into` included), a `BufferedSink` and a `TeeSink` from every write form, `#emit` and `#flush`; the error is a state error and never an `EOFError`. A `Buffer` stays readable and writable on its own surface after close — `#snapshot`, `#bytesize`, `#write`, `#emit`, `#flush`, `#read` — and its close still invalidates every view derived from it (`dexpace/io/buffered_source_test.rb`, `Close`; `dexpace/io/buffered_sink_test.rb`, `Close`; `dexpace/io/tee_sink_test.rb`, `Lifecycle`; `dexpace/io/buffer_test.rb`, `Close`) | + +Forty-two rows: 34 ✅, 8 🚫 (`IO-30`–`IO-36`, `IO-39`, all citing design §10.1; `IO-30`'s +behavioural clause kept as a property), 0 ⏳, 0 N/A — 34 + 8 = 42, recounted from the table. The +three things the charter assigns 3a without a new ID are built: §10.2's canonical body +representation is `Dexpace::IO::_Chunked` in `sig/dexpace/io.rbs` and `TypedReads#each`, which makes +every source one; `BufferedSource.over` with its no-ownership exception; and +`Dexpace::IO::MAX_MATERIALIZED_BYTES`. `BODY-13`'s one-helper rule is honoured ahead of 3b by +`Dexpace::StreamError.short_transfer` and `.zero_read` (`dexpace/error/stream_error_test.rb`), and +`XCUT-15`/`XCUT-18` are left satisfiable and not claimed. + +## What was built + +Nine new `lib/` files under `gems/dexpace-core/lib/dexpace/` — exactly the design's Module Layout: +`error/stream_error.rb`, `error/end_of_stream_error.rb`, `io.rb`, and `io/typed_reads.rb`, +`io/buffered_source.rb`, `io/typed_writes.rb`, `io/buffer.rb`, `io/buffered_sink.rb`, +`io/tee_sink.rb` — each with a `sig/` mirror that declares every method, private ones and instance +variables included, because the strict `core` Steep target checks every file and never relaxes +(phase 2's deviation 1), and each with a `test/` mirror; plus three fakes under `test/support/` +(`fake_chunked.rb`, `fake_source.rb`, `fake_sink.rb`), required explicitly by the suites that use +them. Two existing files changed as the design said: `lib/dexpace/closeable.rb` (one method body, +P3-6) and `lib/dexpace.rb` (nine `require_relative`s as one block, in dependency order, after phase +2's). Three repository-root files changed: `.rubocop/cops/dexpace/qualified_core_constant.rb` +(`SHADOWED` gains `IO`, `WATCHED` becomes the one-segment `Dexpace`, and the definition-site guard), +`.rubocop.yml` (the cop's `Include:` is `gems/*/lib/**/*.rb`) and `.rubocop/test/cops_test.rb` +(eight rejected and ten accepted rows in a second nested class, and one phase-2 row moved from +accepted to rejected). Two further files the plan did not name: `tools/rbs_surface.rb`, whose +`NFR-11` allowlist gains `EOFError`, `IOError` and `Encoding` with a fixture and a pinning test +(deviation 6 below), and the core smoke suite `test/dexpace_test.rb`, whose constant list gains the +streaming layer. The gemspec is untouched — zero `add_dependency` lines — and **3a adds no +`require` of any kind beyond `require_relative`**. + +The gates, all seventeen, on **4.0.6** (`bundle exec rake`, 2026-09-15), on the tests tip: green, exit +0 — `cops:test` 100 runs / 326 assertions, `steep` no type error over the strict `core` target, +`test:gems` 774 runs / 4,630 assertions across the six gems, 757 / 4,559 of them `dexpace-core`'s, +with **99.95% line coverage (2,218 / 2,219)** against the 80% floor — the one uncovered line is the +same one phase 2 recorded, the registry claim swap's race-only branch — `test:gates` 129 runs, the +nine `gates:*` tasks, `yard` 100.00% documented (262 methods, 0 undocumented), `bundler_audit` +clean. On the code tip alone `test:gems` is 530 runs, 0 failures, 0 errors and **78.00%** line +coverage, red on the SimpleCov floor and on nothing else, since the suites that raise it are the +tests tip's; every other gate is green there. The same caveat about `rubocop` that phases 1 and 2 recorded: run +through `rake` from a worktree nested under the parent checkout's `.claude/` it inspects 9 files; +run as `bundle exec rubocop --fail-level=convention --ignore-parent-exclusion` it inspected **200 +files, no offenses**, and that is the run these rows rest on (already on phase 10's inbound list). +The plan's "the whole-repository RuboCop run is not clean" finding does not hold against the built +tree: phase 0 disabled `Layout/EmptyLineAfterMagicComment` in favour of `Dexpace/SpdxHeader` and +phases 1 and 2 met every metric cop with a reasoned inline directive or a smaller method, and 3a +did the same (two `Metrics/ModuleLength` directives on the vocabularies, one `Metrics/ClassLength` +on `BufferedSource`, and `store_take`/`store_peek`/`copy_to` reshaped under `Metrics/AbcSize`). + +The matrix set (`test:gems gates:gemspec_audit gates:require_allowlist gates:clean_bundle +gates:single_instance`) is green on **3.2.11**, **3.3.12** and **3.4.10** at the tests tip, each +with its own lockfile resolved fresh — 774 runs / 4,630 assertions on each, 99.95% line coverage — +and on the code tip `test:gems` on 3.2.11 is 530 runs, 0 failures, 78.83%, the floor again the only +red. The five interpreter-sensitive assertions Task 15 Step 6 names are green on every row: +the `StringIO`-backed BINARY read (`buffered_source_test.rb`, `Bridge`, "a StringIO-backed source +returns BINARY on every interpreter in the matrix"), the frozen-chunk ingress retag (`Over`, "over +survives a body that yields frozen literals"), the `Enumerator` `ensure` asymmetry (`Over`, "closing +an unexhausted over source leaves the wrapped body's own ensure unrun"), the two-fiber `ThreadError` +(`Threads`, "two fibers of one thread interleave reads"), and `IO.copy_stream`'s buffer reuse and +`EOFError`-subclass termination (`Bridge`, "IO.copy_stream drives a wrapped source"; +`end_of_stream_error_test.rb`, "IO.copy_stream terminates on it"). A sixth, found during the build: +the block form of `module_eval` resolves a constant in the block's own lexical scope on all three +interpreters, so the `Dexpace::IO` shadow is probed with string evals (`io_test.rb`). + +The surface manifest `test/fixtures/surface/dexpace-core.txt` grew from 318 to 367 lines through +`bundle exec rake surface:regenerate`, once, after Task 14; the 49 added lines were read one by one +against the plan's Task 15 Step 3 list and are exactly it, with two differences stated below: +`Dexpace::IO::TeeSink#clear_tap` is absent (deviation 1) and +`Dexpace::IO::BufferedSource.__dexpace_view` is present (deviation 7). `Dexpace::IO::Buffer#` carries +four names and `TypedReads#` fifteen, which is P3-8's whole reason for keeping the two vocabularies +public. No `private_constant` appears: `BufferedSource::View`, `TypedReads::READ_SEGMENT_BYTES` and +`TypedWrites::WRITE_ALL_SEGMENT_BYTES` are absent, and the three RBS interfaces carry no runtime +constant. `gates:sig_diff` still prints "no release tag yet". `gates:rbs_surface` was watched go red +on the three core names and green once admitted. + +## Guards run red + +Every guard the plan asks to be run red was run red, on 4.0.6, and restored; the Task 2 guard was +also run red on 3.2.11 and 3.4.10. What each said: + +| Fix reverted, or not yet applied | Guard | What it said | +|---|---|---| +| `Closeable#closed?` reads `@dexpace_closed` directly (phase 2's reader) | `closeable_test.rb`, "closed? acquires the close mutex" | `ThreadError expected but nothing was raised` — the one assertion that distinguishes P3-6 from phase 2's reader on CRuby, on 3.2.11, 3.4.10 and 4.0.6 alike | +| `IO` not yet in `SHADOWED`, `Dexpace` not yet watched | `cops_test.rb`, `QualifiedCoreConstantIOTest` | 6 failures: the four `IO` rejections and the two widened-watch rejections (`Mutex` under `Dexpace`, `Queue` under `Dexpace::Http`) produced no offense. The two definition-site rows cannot fail until `IO` is shadowed, so the plan's "8" is 6 | +| `#fill_once_if_empty` back to `ensure_buffered(1)` (the pre-amendment fence) | `buffered_source_test.rb`, `Counts`; `typed_reads_test.rb` | `Expected: 4096 Actual: 1`, `Expected: [65536, 65536] Actual: [1, 1]`, and three more on the vocabulary's own count-passing assertions — the check that makes the plan's Step 4a a step | +| `Dexpace::IO::BufferedSource` absent (after Task 9, before Task 10) | `typed_reads_test.rb` | `uninitialized constant Dexpace::IO::BufferedSource` on the 20 view-dependent tests and nothing else, as the plan's Step 5 predicts | + +**The Tasks 5–9 ladder**, the finished `typed_reads_test.rb` (81 runs) run against each stage's +`lib/dexpace/io/typed_reads.rb` with the rest of the finished tree: + +| After task | `typed_reads_test.rb` | +|---|---| +| 5 | 81 runs, 123 assertions, **12 failures, 57 errors** | +| 6 | 81 runs, 157 assertions, **4 failures, 47 errors** | +| 7 | 81 runs, 218 assertions, **4 failures, 38 errors** | +| 8 | 81 runs, 249 assertions, **1 failure, 20 errors** | +| 9 | 81 runs, 355 assertions, **1 failure, 0 errors** | +| 10 | 81 runs, 355 assertions, **0 failures, 0 errors** | + +The stage-9 failure is the transitive-invalidation test that Task 10's protocol change closes +(deviation 3); the shape is the plan's ladder, with the plan's pre-amendment counts replaced by the +built tree's. + +## Audit groups run + +The phase-start pair first, at implementation: `--origin note --brief` returns 52 note entries +across 21 files, the three this phase's design filed among them; `--section conflicts --brief` +returns the six harvested conflicts, every one `[overridden by notes/…]`, and 19 note-side entries. +`--prefix-info IO` reports 42 IDs, 38 substantive, 0 roll-ups, 4 uncited; `--gaps IO` names +`IO-32`–`IO-35` as appendix-C only, and this phase read nothing for them beyond the four 🚫 rows. +`--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; at implementation the three that bite were re-checked +against the built code rather than re-run in full: + +| Group | Result at implementation | +|---|---| +| Encoding and binary strings | Clean against the built code: every chunk in every store is a frozen BINARY `String`, the one ingress retag is `#store_append` (keep a frozen BINARY chunk, `#b` everything else; `force_encoding` appears only on the caller's own fresh copy in `#read_string` and `#finish_line`), `#read_into` refuses a non-BINARY destination, `#read`/`#readpartial` pin `outbuf` BINARY (P3-13), and every encoding fixture is non-ASCII | +| Fiber scheduler, thread safety | Clean against the built code: the only mutex is `Closeable`'s, acquired once per public entry point through `#closed?` and never across a fill, a drain, a `#release` or a callback; a view driving its parent acquires the parent's latch once per fill, never per byte; `Timeout.timeout`, `Thread#raise` and `Thread#kill` appear nowhere and phase 0's cop stands guard | +| Public API surface | `module-organization/1828a984` (one public constant per file) holds for the nine files; `api-design/b0e18938` is why every name arriving beyond design §3's list is in P3-8 and why `#clear_tap` was dropped rather than shipped uncalled (deviation 1); `api-design/88e6bf12` is honoured by `respond_to?` at every caller-supplied stream and no `is_a?(IO)` anywhere (`io_test.rb` pins the shadow it guards against) | + +The three notes the design filed stand as written; no fourth was needed. The corpus note the plan's +Task 10 amendment implies — that `fill(1)` was a throughput defect — is a plan-level finding already +closed by the amendment and earned no note. + +## Deviations from the plan + +Departures from the plan's text, each with its reason. None lowers, disables or narrows a gate; one +widens a gate's allowlist in the correct direction (item 6) and one moves a gate test's row from +accepted to rejected (item 2). + +1. **`TeeSink#clear_tap` is dropped**, taking the first of the two resolutions Task 14's + 2026-09-13 amendment names. 3b satisfies `BODY-18` by building a fresh tee per write of the + wrapped body, which `TeeSink` binding its primary at construction forces — a retry writes to a + different sink — so the method had no caller anywhere in core while being NFR-4-locked surface, + and phase 5a's P5-11 (no `unwrap` method ships when nothing calls it) is the precedent. The test + row, the fragment, the `.rbs` line and the manifest line are gone together; `tee_sink_test.rb` + pins the absence. The design's 3a→3b contract table and P3-8 list the method; the design's "As + built" addendum records the drop, and 3b's design (its contract row 1) and plan (its Task 10, + which already says "3a's plan, Task 14 decides whether it stays") read the decision from here. +2. **Phase 2's accepted cop row `module Dexpace; module Transport; Thread.new` is now a rejected + row.** The plan says the one-segment watch "subsumes phase 2's two" and lists phase 2's six + rejections as still rejected, but does not say that phase 2's accepted case 3 — a bare `Thread` + in an unwatched namespace — becomes an offense under it. It does, by P3-7's own rule ("inside + `module Dexpace`, anywhere"); the row moved from `ACCEPTED` to `REJECTED` with a comment, a + tightening and not a narrowing. Phase 3a's own rows are a second nested class, + `CopsTest::QualifiedCoreConstantIOTest`, for the 100-line cap (phase 2's deviation 3). +3. **View invalidation cascades, and the parent-side view entry points check readability.** The + plan's `#dexpace_invalidate` set one flag on one view, so a slice of a slice survived its root's + close: the inner view's own flag was untouched and its reads reached through the invalidated + outer view into the root's store — or, for a `.of_bytes` root, pulled fresh bytes from the root's + enumerator after the root had been closed. `IO-22`'s "every outstanding slice derived from it" + reaches a slice of a slice, so `#dexpace_invalidate` now releases the view's own views, and + `#dexpace_ensure_buffered`/`#dexpace_window_copy` call `#ensure_readable` first, so a closed + stream-backed parent or an invalidated intermediate serves nothing to any view. Found by a test + the plan did not have ("closing the parent invalidates a slice of a slice, transitively"), run + red before the change. +4. **The unbounded window is `nil`, not `Float::INFINITY`.** `#peek`'s window, `View#window`, + `#dexpace_remaining_window` and `#build_view`'s arithmetic are `Integer?` throughout, with `nil` + meaning "no known bound"; `TeeSink`'s `tap_limit:` default is likewise `nil`, with + `Float::INFINITY` accepted as the other spelling and any finite `Float` refused. Strict Steep + refused the plan's `Integer | Float` arithmetic at three sites, and an `Integer?` window is the + honest type anyway: no view ever has a fractional or infinite budget. The public signatures are + unchanged (`#peek` takes nothing; `TeeSink.new`'s keyword keeps its name). +5. **`sig/` declares every private method and instance variable**, and the plan's partial mirrors + are completed accordingly (`TypedReads` gains `#take_from_head`, `Buffer` gains + `#validate_window!`, `BufferedSink` gains `#write_through` and `#check_full_write`, `TeeSink` + gains `#forward`, `#check_full_write` and `#validated_tap_limit`, `BufferedSource` gains + `#window_budget` and `#pull_from_upstream`); `BufferedSource::View` and the two segment + constants are declared with a comment saying the declaration exists for Steep. Phase 2's + deviation 1 is the precedent. Three parameters the plan typed with an RBS interface are + `untyped` — `BufferedSource.over(chunked)`, `TypedWrites#write_all(source)` and + `TeeSink.new(primary:)` — because an RBS interface carries only its own methods and the runtime + check on each is a `respond_to?` duck test; `_Chunked`, `_Source` and `_Sink` stay in + `sig/dexpace/io.rbs` as the shapes a consumer's own `steep check` can type its objects against, + and 3b's body interface can include `_Chunked` as the design intends. +6. **`tools/rbs_surface.rb`'s `NFR-11` allowlist gains `EOFError`, `IOError` and `Encoding`**, the + same false positive phase 1 fixed for `Data`, `ArgumentError` and `StringScanner`: three core + Ruby names the first I/O signatures needed. The fixture `test/fixtures/gates/rbs_surface/gems/ + fixture/sig/io_superclasses.rbs` and a pinning test in `test/gates/rbs_surface_test.rb` keep + them admitted; the gate was watched red on all three before and green after. +7. **`BufferedSource.__dexpace_view` is a public singleton method with an RBS declaration and a + YARD block**, where the design calls the view constructor "internal — no RBS signature, no YARD + block". Strict Steep treats an undeclared `def self.` as a diagnostic that fails the gate, and the + caller (`TypedReads#build_view`) is outside the class, so a private class method cannot serve. It + appears in the surface manifest under its underscore name, its YARD block says it is not public + API, and it is documented nowhere else. +8. **Test files nest a class per behaviour group** for the 100-line cap (phase 2's deviation 4): + `typed_reads_test.rb` (`Rejections`, `Typed`, `Charsets`, `Ceiling`, `Lines`, `Bridge`, `Each`, + `Views`, `ViewLifecycle`, `Includer`), `buffered_source_test.rb` (`Bridge`, `Over`, `Close`, + `Threads`, `Counts`), `typed_writes_test.rb` (`WriteFrom`, `Pump`, `Charsets`, `Includer`), + `buffer_test.rb` (`Snapshot`, `CopyTo`, `Close`), `buffered_sink_test.rb` (`Staging`, `Close`), + `tee_sink_test.rb` (`Failure`, `Lifecycle`, `Vocabulary`), each over a shared factory module. +9. **`store_take` and `store_peek` are reshaped under `Metrics/AbcSize`**: `#take_from_head` is + the one byteslice-and-advance step both `store_take` paths share, and `store_peek` walks with a + running skip instead of a position and a start; `Buffer#copy_to`'s window check is + `#validate_window!`. The plan predicted these two offenses and left them for phase 0's baseline; + the baseline is clean, so they were fixed here. +10. **`fill` in `BufferedSource` is split** into `#pull_from_upstream` (the one `rescue ::EOFError`, + around both `#readpartial` and `#read`) and `#fill_from_upstream`, and `#fill_from_parent` + takes the view as an argument; `BufferedSink#push_one_level` and `TeeSink#deliver` write + through a one-line `ensure`-wrapped method (`#write_through`, `#forward`) so Steep can see the + result's type, with the short-write check in `#check_full_write` on each. Behaviour is the + plan's. +11. **`require_relative` lines the plan's fences omit** — `error/seam_error` in both vocabularies, + `error/stream_error`, `error/invalid_argument_error` and `error/closed_error` where each is + raised — are present, so every file names what it raises; the require audit sees only + `require_relative`. +12. **Run counts exceed the plan's.** The `IO` suites are 245 runs where the plan's built-tree run + was 209 for the whole tree, because the nested classes carry extra cases the plan did not have + (the readability checks, the count-passing guards on the vocabulary's own side, the pipe-end + sink, the transitive invalidation, the consumer-side shadow probe); the plan's step-level + expected counts ("PASS, 14 tests", "PASS, 3 tests") are therefore not matched and were not + meant to be. Two of them could not have held as written: Task 4's `io_test.rb` pins the whole + constant list, which is complete only after Task 14, and Task 5's negative-count test calls + `#read_exactly`, which Task 6 adds. +13. **The plan's expectation for the segment guard's second assertion is `[65536, 65536]`, not + `[65536]`**: `#each` asks the upstream once for the chunk and once more to find the end, and + the plan's fake raises on the second ask after recording it. The fill-count assertion on the + vocabulary side counts probes the same way. +14. **The `Dexpace::IO` shadow is probed with string evals.** The plan's io_test has no such test; + the one written first used `Dexpace.module_eval { IO }`, which resolves `IO` in the block's own + lexical scope — to `::IO` — on 3.2.11, 3.4.10 and 4.0.6 alike, and was caught by the floor run + after Task 12 (the first run of that file on any row after Task 4). `module_eval("IO", __FILE__, + __LINE__)` and a consumer class's `class_eval("…is_a?(IO)…")` state exactly the design's + finding, both halves. +15. **`Buffer#snapshot` reuses `TypedReads#guard_materialization!`** rather than a private + `#ensure_snapshot_within_ceiling` of its own, so the six guarded sites share one message form. +16. **The entry file's nine `require_relative`s are one block appended after phase 2's**, in + Task 15's dependency order, where Task 3 said "immediately after `error/cancelled_error`" + (phase 2's deviation 15, again). +17. **`rbs:validate` was red between Tasks 5 and 10 and between Tasks 11 and 12 on the wip branch**, + on the forward references to `Dexpace::IO::BufferedSource` and `Dexpace::IO::Buffer` that the + plan's own signatures carry; the plan's per-task "then `rake rbs:validate steep`" cannot pass + there. Steep was green at every task where `rbs:validate` was. + +## Findings routed + +- **The plan's RuboCop-baseline finding does not reproduce** against the built tree (see "What was + built"); nothing to route, and phase 0's plan, Task 3 gains no item from here. +- **3b's design (contract row 1) and P3-8 name `TeeSink#clear_tap`, which no longer exists** — + recorded in the design's "As built" addendum (this phase may write it) and read by 3b's Task 10 + from there; 3b's own documents are not edited by this phase. +- **Phase 2's cop row flipped** (deviation 2) — in material this phase may write; fixed in place. +- **The RBS interfaces cannot type a duck-checked parameter** (deviation 5) — a Ruby fact about RBS, + not a finding against any gate; recorded in the sig comments where a later phase meets it. +- **`rake rubocop` is vacuous in a worktree nested under the parent checkout's `.claude/`** — + already on phase 10's inbound list from phase 1; the honest run is recorded above. + +## Postponed work + +The one item the design postponed keeps its owner: exercising `IO-38` on a Ruby without a GVL is +`docs/first-release.md` § Post-release triggers, cited at `IO-38`'s row. The items earlier phases +postponed were re-read on 2026-09-15 and keep the owners the design's "Work Phase 3a Postpones" +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; three more doubles +strengthen the case without meeting the condition), `Hooks.notify`'s dropped failures (phase 4b, +Task 2), `SEAM-25`'s lifecycle event (phase 8b, Tasks 6 and 10; phase 9, Task 11), the body +member's type and `HTTP-46` (3b, P3-15). The unbounded `#read_line_utf8` stays with phase 7b's plan, +Task 12; the `include Dexpace` shadow's documentation half is now written in +`docs/sdk-documentation/io.md` and its release-decision half stays under `docs/first-release.md` +§ Blockers before first publish; the `IO-6` appendix-C finding stays with the roadmap's gap +paragraph. The implementation postponed nothing further. diff --git a/docs/work/mvp/phase3/phase3a/2026-09-08-phase3a-io-contracts-design.md b/docs/work/mvp/phase3/phase3a/2026-09-08-phase3a-io-contracts-design.md index a7ebf15..0f5314b 100644 --- a/docs/work/mvp/phase3/phase3a/2026-09-08-phase3a-io-contracts-design.md +++ b/docs/work/mvp/phase3/phase3a/2026-09-08-phase3a-io-contracts-design.md @@ -1126,6 +1126,67 @@ from the phase-3 segmentation design, which left the ledger empty. | P3-11 | `BufferedSource.new` and `BufferedSink.new` are `private_class_method`; `Buffer.new` and `TeeSink.new` are public | `IO-6`; design §3.7 | Ownership is a construction-time fact, and a public `.new` taking an ownership argument would let a caller build the wrapper `IO-6` forbids — one that wraps a caller's stream and does not close it. `Buffer` and `TeeSink` each have exactly one construction meaning and need no factory to name it. Phase 1's `private_class_method :new` plus a validating `.build` is a rule about `Data` value types and does not reach these, which are mutable and stateful | | P3-12 | `.wrapping` takes a block form; there is **no borrowing variant** of either wrapping factory | `IO-6`; `resource-management/bf5560dc`, `/43a55896` | The styleguide's strongest resource rule asks for a block form for every closable resource, and 3a adopts it exactly where a leak is possible. A borrowing wrap is not offered because `IO-6` is a MUST that a wrapper closes what it wraps; §3.7's build-versus-borrow split governs components that hold a resource, not the I/O wrap, and reading it as licence here would contradict the requirement §10.12 depends on | +### As built, 2026-09-15 + +The plan added one row, **P3-13**, and execution added none; the plan's Deviation Ledger carries +P3-13's full text, and it is repeated here so the design's ledger is the whole set for +consolidation into design §10: + +| # | Deviation | Requirement / document | Why | +|---|---|---|---| +| P3-13 | `#read` and `#readpartial` leave `outbuf` tagged `Encoding::BINARY` whatever tag it arrived with, where `::IO#read` preserves the destination's tag | `IO-16`; design §3.1's BINARY rule; verified fact 11 | The bridge's contract is "Ruby's semantics", and on this one point Ruby's own readers disagree with each other and across this port's floor: `::IO#read(n, buf)` preserves a UTF-8 destination's tag on 3.2.11, 3.4.10 and 4.0.6, while `StringIO#read(n, buf)` gives ASCII-8BIT on 3.2.11 and UTF-8 from 3.4.10. Pinning BINARY is the one answer identical on every matrix row and behind every backing stream. `IO.copy_stream` never reads the destination's tag, so R4's bridge claim is unaffected — asserted end to end in `buffered_source_test.rb` | + +Five rows and two statements above read slightly differently against the source; the difference +is recorded here rather than by rewriting the text it corrects. The checklist's "Deviations from +the plan" is the itemised list. + +- **P3-8, as built.** `TeeSink#clear_tap` is **not** shipped: the plan's Task 14 amendment carried + the keep-or-drop decision, and it was dropped, because phase 3b satisfies `BODY-18` by building a + fresh tee per write — forced by `TeeSink` binding its primary at construction — so the method had + no caller anywhere in core while being `NFR-4`-locked surface. The 3a→3b contract table above + and P3-8's list name it; the surface manifest, the RBS signature and the test do not. The row + gains one name it did not list: `BufferedSource.__dexpace_view`, the view constructor, is a public + singleton method with an RBS declaration and a YARD block (which says it is not public API), + because strict Steep refuses an undeclared `def self.` and the caller lives in `TypedReads`, + outside the class; the object model's "internal — no RBS signature, no YARD block" sentence does + not hold and is corrected by this note. +- **P3-5, as built.** Invalidation cascades: a view's `#dexpace_invalidate` releases the view's own + views, and the two protected entry points a view drives its parent through check the parent's + readability first, so a slice of a slice is invalidated when the root closes and an invalidated + intermediate serves nothing. The plan's single-flag protocol let an inner view pull fresh bytes + through an invalidated outer one from a closed `.of_bytes` root; `IO-22`'s "every outstanding + slice derived from it" reaches transitively. The retention rule is unchanged. +- **P3-7, as built.** The widening moved one phase-2 cop case from accepted to rejected — a bare + `Thread` inside `Dexpace::Transport`, accepted in phase 2 because nothing reopens that name, and + an offense under the one-segment watch by the rule's own words. A tightening, recorded because + the R1 section above lists phase 2's rejections as unchanged and says nothing of its acceptances. +- **P3-3, as built.** `Dexpace::StreamError < ::IOError` and `Dexpace::EndOfStreamError < + ::EOFError` needed `tools/rbs_surface.rb`'s `NFR-11` allowlist to admit `EOFError` and `IOError` + (and `IO-13`'s reads, `Encoding`) — the false positive phase 1 fixed for `Data`, `ArgumentError` + and `StringScanner`, fixed the same way with a fixture and a pinning gate test. The "The RBS + interfaces" sentence "3a's whole public surface names no stdlib type but `String`, `Integer` and + `Encoding`" holds for the interfaces and undercounts the classes by the two superclasses. +- **The RBS interfaces, as built.** `_Source`, `_Sink` and `_Chunked` exist in `sig/dexpace/io.rbs` + as written, but no 3a signature takes one as a parameter type: an RBS interface carries only its + own methods, and `BufferedSource.over`, `TypedWrites#write_all` and `TeeSink.new(primary:)` each + check their argument with `respond_to?` — which strict Steep refuses to send to an interface + type — so all three are `untyped`, as `.wrapping`'s `io` already was. The interfaces are the + shapes a consumer's own `steep check` types its objects against, and 3b's body interface can + include `_Chunked` as intended. +- **The unbounded window, as built.** `#peek`'s window, `View#window` and + `#dexpace_remaining_window` are `Integer?` with `nil` meaning unbounded, not `Float::INFINITY`; + `TeeSink.new`'s `tap_limit:` defaults to `nil` and accepts `Float::INFINITY` as the other + spelling while refusing a finite `Float`. Strict Steep refused `Integer | Float` arithmetic at + three sites, and no view or tap ever has a fractional or infinite budget. No public signature + changes. +- **The testing strategy's `Dexpace::IO` probe, as built.** `io_test.rb` measures the shadow with + string evals — `Dexpace.module_eval("IO", __FILE__, __LINE__)` and a consumer class's + `class_eval("::IO.pipe.first.is_a?(IO)", …)` — because the block form of `module_eval` resolves a + constant in the block's own lexical scope on all three interpreters, so the obvious probe reports + `::IO` and proves nothing. Both halves of the finding under "The finding, and who owns it now" are + pinned; the release half stays with `docs/first-release.md`, and the documentation half is now + written in `docs/sdk-documentation/io.md`. + ## Work Phase 3a Postpones, and Who Owns It Now One item, recorded on 2026-09-08 with an explicit pick-up condition, per the roadmap's execution step 7. diff --git a/gems/dexpace-core/README.md b/gems/dexpace-core/README.md index e827a60..e5a18ed 100644 --- a/gems/dexpace-core/README.md +++ b/gems/dexpace-core/README.md @@ -3,17 +3,21 @@ 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 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`. +**Status: `0.0.0`, unpublished; the HTTP domain model, the seam layer and the byte-streaming +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` -- 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` -- and phase 3a's byte-streaming layer under `Dexpace::IO`: the FIFO +`Buffer`, `BufferedSource` and `BufferedSink` with the `TypedReads` and `TypedWrites` +vocabularies, `TeeSink`, `MAX_MATERIALIZED_BYTES`, and the two failure types +`Dexpace::StreamError` and `Dexpace::EndOfStreamError`. 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`, `docs/sdk-documentation/seams.md` +and `docs/sdk-documentation/io.md`. ## Install @@ -56,6 +60,28 @@ end Dexpace::Transport.resolve # raises Dexpace::SeamError: no transport provider is registered ``` +Bytes stream through `Dexpace::IO`: a buffered source over any `#readpartial`-shaped stream owns +it, reads through Ruby's own `#read`/`#readpartial` (so `IO.copy_stream` can drive it) or the +tail-appending primitive `#read_into`, and hands out non-consuming views; a tee mirrors a bounded +tap while the wire body goes through untouched: + +```ruby +require "stringio" + +Dexpace::IO::BufferedSource.wrapping(StringIO.new("one\ntwo")) do |source| + source.peek.read_line_utf8 # => "one", and the source's cursor has not moved + source.read_exactly(4) # => "one\n".b -- every byte that comes back is BINARY + source.read_utf8 # => "two" +end # closes the StringIO: wrapping takes ownership + +tee = Dexpace::IO::TeeSink.new(primary: Dexpace::IO::Buffer.new, tap_limit: 3) +tee.write("héllo") # => 6, the byte count; the tap keeps "h\xC3\xA9", the primary all of it +``` + +One thing to know before writing `include Dexpace` in a class of your own: `Dexpace::IO` shadows +`::IO` there, so `x.is_a?(IO)` is silently false for a real `IO`. Write `::IO` +(`docs/sdk-documentation/io.md` says why). + ## Depends on Nothing. `dexpace-core.gemspec` has no `add_dependency` line, and `rake gates:gemspec_audit` asserts @@ -66,6 +92,8 @@ it stays that way (`SEAM-1`, `NFR-1`). - `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/io.md` -- the byte-streaming layer as built: the two read primitives, + the ownership rule, views, the buffer, the tee, and the `Dexpace::IO` shadow. - `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. From 0d8ab4910f3e813eafb62327e0b860f7c8acf910 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Tue, 15 Sep 2026 22:14:12 +0300 Subject: [PATCH 8/8] docs: record review round 0's fixes in the phase 3a checklist and pages The views example on docs/sdk-documentation/io.md claimed the final slice.read raised Dexpace::ClosedError; as built it returned "", because the slice had already pulled its whole window and P3-5's "behind" failure needs a byte still unread (R0-2). The example now takes a three-byte slice and reads one byte before the parent passes it, and was re-run on 4.0.6 and 3.2.11; the section also states the one-fill rule a view now shares with the root. The checklist gains deviations 18 (a view's fill is one fill past its position, never a loop to the count) and 19 (the two wrapped .rbs lines), rewrites deviation 7 for the now-private view constructor, extends deviation 14 with the lambda probes, adds the round-0 guard-red table and the routed findings, renames #dexpace_ensure_buffered in the IO-22 row and deviation 3, cites the new suites from IO-1, IO-16 and IO-19, and carries the round-1 tip's numbers: 783 runs / 4,669 assertions, 2,222 / 2,223 lines, 261 documented methods, a 366-line manifest, 77.91% and 78.74% on the code tip. The design's As-built addendum corrects its P3-8 note and gains the view-fill note; the roadmap's status note follows. --- docs/sdk-documentation/io.md | 15 ++- .../2026-09-05-ruby-sdk-v1-roadmap-design.md | 14 ++- ...26-09-08-phase3a-io-contracts-checklist.md | 106 +++++++++++++----- .../2026-09-08-phase3a-io-contracts-design.md | 26 +++-- 4 files changed, 116 insertions(+), 45 deletions(-) diff --git a/docs/sdk-documentation/io.md b/docs/sdk-documentation/io.md index 4e6e378..d9a7e46 100644 --- a/docs/sdk-documentation/io.md +++ b/docs/sdk-documentation/io.md @@ -125,15 +125,19 @@ BINARY chunks, so it can be handed to anything that consumes one. A view is a `BufferedSource` over another source that reads without advancing its parent's cursor. `#peek` covers the whole remaining source; `#slice(offset:, count:)` covers at most `count` bytes starting `offset` ahead of the cursor. Both drive the parent's fill as needed, so a view can -read bytes the parent has not pulled from its upstream yet. +read bytes the parent has not pulled from its upstream yet — and they drive it the way the root +does: `#read_into`, `#readpartial` and `#each` on a view fill the parent once, until a byte the +view wants has arrived, and return what came with it, while `#read(n)` and `#read_exactly(n)` fill +to the count. A peek over a live response body returns what the peer has sent so far rather than +waiting for `count` bytes that may never come. ```ruby source = Dexpace::IO::BufferedSource.of_bytes("abcdef") source.peek.read # => "abcdef" source.read_exactly(2) # => "ab" -- the peek consumed nothing -slice = source.slice(offset: 1, count: 2) -slice.read # => "de" -source.read # => "cdef" +slice = source.slice(offset: 1, count: 3) +slice.read_exactly(1) # => "d" -- "ef" still unread in the window +source.read # => "cdef" -- the parent passes the slice's next byte slice.read # raises Dexpace::ClosedError: the window is "behind" the cursor ``` @@ -150,7 +154,8 @@ this port fixes: - **The parent holds nothing back for a view.** Its retention floor is its own cursor: a view reading ahead of the parent retains what it reads ahead, and a view whose next byte the parent has already consumed fails loudly rather than serving bytes from somewhere else. Bytes a view - has already read are behind it and the parent may pass them freely. + has already read are behind it and the parent may pass them freely: a slice that has pulled its + whole window keeps answering `""` at its end whatever the parent reads afterwards. ## The buffer 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 b7c8236..f1b9d3a 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 @@ -2707,8 +2707,8 @@ to a socket. The checklist is at `docs/work/mvp/phase3/phase3a/2026-09-08-phase3 forty-two rows, 34 ✅ and 8 🚫 (`IO-30`–`IO-36`, `IO-39`, the retired provider apparatus citing design §10.1, with `IO-30`'s independent-copy clause kept as a property of `.of_bytes`), nothing ⏳, nothing N/A. `bundle exec rake` is green on 4.0.6 at the tests tip with 99.95% line coverage against the 80% floor and -774 runs across the six gems; the matrix set is green on 3.2.11, 3.3.12 and 3.4.10; the core suite is green -under three further seeds; the code tip is red on the SimpleCov floor alone (78.00%, 0 failures). **Every guard the +783 runs across the six gems; the matrix set is green on 3.2.11, 3.3.12 and 3.4.10; the core suite is green +under further seeds; the code tip is red on the SimpleCov floor alone (77.91%, 0 failures). **Every guard the plan asks to be run red was run red** — P3-6's fiber-held-mutex assertion against phase 2's reader on all three interpreters (`ThreadError expected but nothing was raised`), the cop's six new rejections before the widening, the `fill(1)` amendment's count guards (`Expected: 4096 Actual: 1`), and the Tasks 5–9 ladder @@ -2719,8 +2719,12 @@ outer view from a closed `.of_bytes` root; `#dexpace_invalidate` now releases a parent-side entry points check readability first (checklist deviation 3). **Two decisions the plan left open were taken in the open**: `TeeSink#clear_tap` is dropped — 3b builds a fresh tee per write, so the method had no caller in core while being `NFR-4`-locked surface (deviation 1) — and `BufferedSource.__dexpace_view` is a -declared public singleton method because strict Steep refuses an undeclared `def self.` (deviation 7). -Seventeen departures from the plan's text are itemised in the checklist, none lowering a gate; one widens +`private_class_method` with a `private def self.` RBS declaration, reached through `send`, so the underscore +name is in neither the manifest nor the `NFR-4` diff (deviation 7). **Review round 0 (2026-09-15) found a +view's fill blocking to the count where the root fills once** — a peek over a pipe holding ten bytes hung on +`read_into(count: 100)` — and it was closed on the code branch with `#dexpace_fill_beyond`, nine tests and a +guard run red (deviation 18). Nineteen departures from the plan's text are itemised in the checklist, none +lowering a gate; one widens `tools/rbs_surface.rb`'s `NFR-11` allowlist by `EOFError`, `IOError` and `Encoding` — the false positive phase 1 fixed for `Data`, `ArgumentError` and `StringScanner`, fixed the same way with a fixture and a pinning gate test (deviation 6) — and one moves phase 2's accepted cop row `module Dexpace; module Transport; Thread.new` @@ -2733,7 +2737,7 @@ nothing. The design's ledger gains P3-13 and an "As built" addendum; the one pos documentation half is written in `docs/sdk-documentation/io.md` and the core README with the consumer case pinned in `io_test.rb`, and its release-decision half stays open. The counts that changed: `gems/` is still six; `dexpace-core`'s `lib/dexpace/` is fifty-one phase-1, phase-2 and phase-3a files beside phase 0's -`version.rb`; `phase3/phase3a/` now carries its checklist, the fourth written; the surface manifest is 367 +`version.rb`; `phase3/phase3a/` now carries its checklist, the fourth written; the surface manifest is 366 lines. `CLAUDE.md`'s built-phases paragraph, its gem and phase-directory sentences and the constraints-that-bite line on the seventh cop are rewritten from what was built. The consolidation of P3-1–P3-13 into design §10 is a human's, as it was for phases 1 and 2: `docs/sdk-design-ruby/` is frozen, and `docs/deviations.md` is left as diff --git a/docs/work/mvp/phase3/phase3a/2026-09-08-phase3a-io-contracts-checklist.md b/docs/work/mvp/phase3/phase3a/2026-09-08-phase3a-io-contracts-checklist.md index 35eb14a..a37fe44 100644 --- a/docs/work/mvp/phase3/phase3a/2026-09-08-phase3a-io-contracts-checklist.md +++ b/docs/work/mvp/phase3/phase3a/2026-09-08-phase3a-io-contracts-checklist.md @@ -22,7 +22,7 @@ Forty-two: `IO-1`–`IO-42`. | ID | Level | Status | Task(s) | What was built, and where it is proven | |---|---|---|---|---| -| `IO-1` | MUST | ✅ | 5 | `Dexpace::IO::TypedReads#read_into(dest, count:)` — the primitive, deliberately not `#read` (P3-1): it appends to the tail of a BINARY `dest`, returns at least 1 when `count` is positive and the source is not exhausted, exactly 0 for a zero count, `-1` when exhausted before any byte, never more than `count`, and fills at most once before serving. The count the caller asked for is the count the fill hook receives, so a wrapped stream is asked for `count` bytes and not for one (the plan's 2026-09-13 amendment; guard run red below). Asserted against `#read` on the same non-empty buffer in one test, so a later merge of the two turns red (`dexpace/io/typed_reads_test.rb`, the primitive rows and `Bridge`) | +| `IO-1` | MUST | ✅ | 5 | `Dexpace::IO::TypedReads#read_into(dest, count:)` — the primitive, deliberately not `#read` (P3-1): it appends to the tail of a BINARY `dest`, returns at least 1 when `count` is positive and the source is not exhausted, exactly 0 for a zero count, `-1` when exhausted before any byte, never more than `count`, and fills at most once before serving — through a view too, since review round 0: a `#peek` or `#slice` fills its parent until a byte past the view's own position has arrived and returns what came with it, never looping to `count` (R0-1; `ViewFills`, and `dexpace/io/buffered_source_test.rb`, `Views`, over a live `IO.pipe` with the writer still open; guard run red below). The count the caller asked for is the count the fill hook receives, so a wrapped stream is asked for `count` bytes and not for one, through a view as well (the plan's 2026-09-13 amendment; guards run red below). Asserted against `#read` on the same non-empty buffer in one test, so a later merge of the two turns red (`dexpace/io/typed_reads_test.rb`, the primitive rows and `Bridge`) | | `IO-2` | MUST | ✅ | 5 | A zero count returns 0 and never `-1`, on an exhausted source too, and touches neither the buffer nor the fill hook. The check sits after `IO-42`'s close check, so a closed stream-backed source refuses a zero-count read as it refuses every other (plan decision 5) (`dexpace/io/typed_reads_test.rb`; `dexpace/io/buffered_source_test.rb`, `Close`) | | `IO-3` | MUST | ✅ | 5, 6, 9, 11, 12 | A negative or non-`Integer` count, offset, length or maxlen is `Dexpace::InvalidArgumentError` naming the argument and the value, before any I/O and with no partial side effect — asserted for `#read_into`, `#read`, `#readpartial`, `#read_exactly`, `#skip`, `#read_string`, `#slice`, `#write_from` and `#copy_to`; `#read_into` also refuses a non-`String`, frozen or non-BINARY destination the same way (`dexpace/io/typed_reads_test.rb`, `Rejections`; `dexpace/io/typed_writes_test.rb`, `WriteFrom`; `dexpace/io/buffer_test.rb`, `CopyTo`) | | `IO-4` | MUST | ✅ | 11, 13, 14 | `TypedWrites#write_from(buffer, count:)` removes exactly `count` bytes from the head of a `Dexpace::IO::Buffer` and pushes them; a buffer holding fewer raises `Dexpace::StreamError.short_transfer` naming both counts and consumes nothing; a zero count is a no-op; a non-`Buffer` is refused. Re-tested against a real `Buffer` into a `BufferedSink` over a `StringIO` and through a `TeeSink` (`dexpace/io/typed_writes_test.rb`, `WriteFrom`; `dexpace/io/buffered_sink_test.rb`; `dexpace/io/tee_sink_test.rb`, `Vocabulary`) | @@ -37,13 +37,13 @@ Forty-two: `IO-1`–`IO-42`. | `IO-13` | MUST | ✅ | 6, 11 | Read side: `#read_utf8` and `#read_string(encoding)` retag the drained BINARY bytes with the named `Encoding` (or name), validate nothing — `#valid_encoding?` is the caller's question and `HTTP-42`'s replacement policy is 3b's — and refuse an unknown name before touching the stream. Write side: `#write_utf8(string, range: nil)` with a character range, and `#write_string(string, encoding:)`, which encodes into the named charset and refuses text it cannot represent. Every fixture is non-ASCII (`dexpace/io/typed_reads_test.rb`, `Charsets`; `dexpace/io/typed_writes_test.rb`, `Charsets`) | | `IO-14` | MUST | ✅ | 7 | `#read_line_utf8`, hand-implemented over the chunk store and never `#gets`: `"\n"` and `"\r\n"` terminate and are consumed, a lone `"\r"` is content, a final unterminated line comes back as-is, `nil` when exhausted before any byte, a terminator split across two chunks is one terminator, the result is UTF-8-tagged. A 48-sample property over a pool including `"a\rb"`, `"é"` and `"hé\rllo"` mixes both terminators, an optional missing final one, and random chunk splits (`dexpace/io/typed_reads_test.rb`, `Lines`) | | `IO-15` | MUST | ✅ | 6 | `#skip(count)` advances past exactly `count` bytes across chunk boundaries, raises `Dexpace::EndOfStreamError` consuming nothing when fewer remain, and `#skip(0)` is a no-op at and after EOF (`dexpace/io/typed_reads_test.rb`, `Typed`) | -| `IO-16` | SHOULD | ✅ | 8, 10, 11, 13 | The bridge is the source (R4): `#read(length = nil, outbuf = nil)`, `#readpartial(maxlen, outbuf = nil)`, `#getbyte`, `#readbyte` and `#each` carry Ruby's own semantics, end of stream the host-native way — `nil` from `#read`/`#getbyte`, `Dexpace::EndOfStreamError < ::EOFError` from `#readpartial`/`#readbyte` (P3-2) — and `outbuf` comes back BINARY whatever it arrived as (P3-13). Proved rather than restated: `IO.copy_stream` over a `BufferedSource.wrapping(pipe_end)` delivers the payload intact, terminates on the `EOFError` subclass, and closing the source afterwards closes the pipe; `#read(2, buf)` overwrites where `#read_into(buf, count: 2)` appends, in one test. The writable half: `TypedWrites#write(*strings)` returns the byte count, and `IO.copy_stream` writes into a `BufferedSink.wrapping(io)` and into a `TeeSink` (`dexpace/io/typed_reads_test.rb`, `Bridge`; `dexpace/io/buffered_source_test.rb`, `Bridge`; `dexpace/error/end_of_stream_error_test.rb`; `dexpace/io/buffered_sink_test.rb`; `dexpace/io/tee_sink_test.rb`, `Vocabulary`) | +| `IO-16` | SHOULD | ✅ | 8, 10, 11, 13 | The bridge is the source (R4): `#read(length = nil, outbuf = nil)`, `#readpartial(maxlen, outbuf = nil)`, `#getbyte`, `#readbyte` and `#each` carry Ruby's own semantics, end of stream the host-native way — `nil` from `#read`/`#getbyte`, `Dexpace::EndOfStreamError < ::EOFError` from `#readpartial`/`#readbyte` (P3-2) — and `outbuf` comes back BINARY whatever it arrived as (P3-13). Proved rather than restated: `IO.copy_stream` over a `BufferedSource.wrapping(pipe_end)` delivers the payload intact, terminates on the `EOFError` subclass, and closing the source afterwards closes the pipe; `#read(2, buf)` overwrites where `#read_into(buf, count: 2)` appends, in one test. Through a view the bridge keeps Ruby's semantics too, since review round 0: on a peek over a live `IO.pipe` with the writer open, `#readpartial(100)` returns the ten bytes that have arrived and `#each` yields them, as the root does, where before R0-1 both blocked to the count (`dexpace/io/buffered_source_test.rb`, `Views`; `dexpace/io/typed_reads_test.rb`, `ViewFills`). The writable half: `TypedWrites#write(*strings)` returns the byte count, and `IO.copy_stream` writes into a `BufferedSink.wrapping(io)` and into a `TeeSink` (`dexpace/io/typed_reads_test.rb`, `Bridge`; `dexpace/io/buffered_source_test.rb`, `Bridge`; `dexpace/error/end_of_stream_error_test.rb`; `dexpace/io/buffered_sink_test.rb`; `dexpace/io/tee_sink_test.rb`, `Vocabulary`) | | `IO-17` | MUST | ✅ | 11, 13 | `TypedWrites#write_all(source)` pumps anything responding to `#read_into` through 64 KiB reads, terminates only on `-1`, returns the total, and raises `Dexpace::StreamError.zero_read` on a read of 0 for a positive count — unconditionally, because §10.1 retired the adapter and "adapter-native" has no subject here — without spinning; a source's own failure propagates unchanged. `FakeSource` scripts the violation no real stream produces. The symmetric write-side rule: an underlying `#write` that reports fewer bytes than it was handed is `Dexpace::StreamError.short_transfer` (`dexpace/io/typed_writes_test.rb`, `Pump`; `dexpace/io/buffered_sink_test.rb`, `Staging`) | | `IO-18` | SHOULD | ✅ | 11, 12, 13, 14 | `#emit` pushes one level and never calls the underlying `#flush`; `#flush` does both. `BufferedSink` stages and pushes in the same `#deliver`, so the staging buffer is empty between calls (plan decision 4); `Buffer`'s two are the no-ops the requirement permits, returning `self`; `TeeSink`'s forward to the primary only (`dexpace/io/buffered_sink_test.rb`, `Staging`; `dexpace/io/buffer_test.rb`; `dexpace/io/tee_sink_test.rb`, `Lifecycle`) | -| `IO-19` | MUST | ✅ | 9, 10 | `#peek` returns a `BufferedSource` in view mode over the whole remaining source — an unbounded window, never "whatever happens to be buffered" — that drives the parent's fill without advancing the parent's cursor (`dexpace/io/typed_reads_test.rb`, `Views`; `dexpace/io/buffer_test.rb`, `Close`; `dexpace/io/buffered_source_test.rb`, `Counts`) | +| `IO-19` | MUST | ✅ | 9, 10 | `#peek` returns a `BufferedSource` in view mode over the whole remaining source — an unbounded window, never "whatever happens to be buffered" — that drives the parent's fill without advancing the parent's cursor, one fill per read and returning what arrived, as on the root (review round 0, R0-1) (`dexpace/io/typed_reads_test.rb`, `Views`, `ViewFills`; `dexpace/io/buffer_test.rb`, `Close`; `dexpace/io/buffered_source_test.rb`, `Counts`, `Views`) | | `IO-20` | MUST | ✅ | 9, 10 | `#slice(offset:, count:)` exposes at most `count` bytes starting `offset` ahead of the cursor, never advances the parent, and reading past the window behaves as end of window for every read form (`dexpace/io/typed_reads_test.rb`, `Views`) | | `IO-21` | MUST | ✅ | 9, 10 | An offset past the end constructs successfully and surfaces on first read as empty/EOF; a negative or non-`Integer` offset or count is refused at construction (`dexpace/io/typed_reads_test.rb`, `Views` and `Ceiling`) | -| `IO-22` | MUST | ✅ | 5, 9, 10, 12 | Closing a view closes neither its parent nor the parent's cursor and releases its registration; closing the parent — a `BufferedSource` or a `Buffer` — invalidates every outstanding view so that every later read on one raises `Dexpace::ClosedError`, **transitively through a view's own views**, and the two parent-side entry points a view drives (`#dexpace_ensure_buffered`, `#dexpace_window_copy`) check the parent's readability first, so a closed or invalidated intermediate serves nothing (deviation 3 below). P3-5's retention rule: a parent read past a live view's pin makes that view fail loudly naming "behind", never serving other bytes; a view that already pulled its window is unaffected (`dexpace/io/typed_reads_test.rb`, `ViewLifecycle`; `dexpace/io/buffer_test.rb`, `Close`) | +| `IO-22` | MUST | ✅ | 5, 9, 10, 12 | Closing a view closes neither its parent nor the parent's cursor and releases its registration; closing the parent — a `BufferedSource` or a `Buffer` — invalidates every outstanding view so that every later read on one raises `Dexpace::ClosedError`, **transitively through a view's own views**, and the two parent-side entry points a view drives (`#dexpace_fill_beyond`, `#dexpace_window_copy`) check the parent's readability first, so a closed or invalidated intermediate serves nothing (deviation 3 below). P3-5's retention rule: a parent read past a live view's pin makes that view fail loudly naming "behind", never serving other bytes; a view that already pulled its window is unaffected (`dexpace/io/typed_reads_test.rb`, `ViewLifecycle`; `dexpace/io/buffer_test.rb`, `Close`) | | `IO-23` | MUST | ✅ | 9, 10 | Two slices of one source have independent cursors and budgets; a slice of a slice composes offsets additively from the outer view's cursor and is capped at the outer window's remaining bytes, over a partially read outer view too, and a 48-sample property pins it over random offsets and budgets. Reading the outer view past the inner view's pin invalidates the inner one (P3-5 applied recursively) (`dexpace/io/typed_reads_test.rb`, `Views` and `ViewLifecycle`) | | `IO-24` | MUST | ✅ | 9, 10 | Every read form on an explicitly closed view — thirteen of them, `#peek` and `#slice` included — raises `Dexpace::ClosedError`, a `StandardError` that is neither an `EOFError` nor a `StreamError` (P3-3) (`dexpace/io/typed_reads_test.rb`, `ViewLifecycle`; `dexpace/io/buffered_source_test.rb`, `Close`) | | `IO-25` | MUST | ✅ | 14 | `Dexpace::IO::TeeSink.new(primary:)` mirrors every write into a `Buffer` tap and forwards the full payload to the primary, whose writes are byte-for-byte identical with or without a tap limit; a 48-sample property asserts tap and primary agree over random chunk sequences (`dexpace/io/tee_sink_test.rb`) | @@ -97,11 +97,11 @@ streaming layer. The gemspec is untouched — zero `add_dependency` lines — an The gates, all seventeen, on **4.0.6** (`bundle exec rake`, 2026-09-15), on the tests tip: green, exit 0 — `cops:test` 100 runs / 326 assertions, `steep` no type error over the strict `core` target, -`test:gems` 774 runs / 4,630 assertions across the six gems, 757 / 4,559 of them `dexpace-core`'s, -with **99.95% line coverage (2,218 / 2,219)** against the 80% floor — the one uncovered line is the +`test:gems` 783 runs / 4,669 assertions across the six gems, 766 / 4,598 of them `dexpace-core`'s, +with **99.95% line coverage (2,222 / 2,223)** against the 80% floor — the one uncovered line is the same one phase 2 recorded, the registry claim swap's race-only branch — `test:gates` 129 runs, the -nine `gates:*` tasks, `yard` 100.00% documented (262 methods, 0 undocumented), `bundler_audit` -clean. On the code tip alone `test:gems` is 530 runs, 0 failures, 0 errors and **78.00%** line +nine `gates:*` tasks, `yard` 100.00% documented (261 methods, 0 undocumented), `bundler_audit` +clean. On the code tip alone `test:gems` is 530 runs, 0 failures, 0 errors and **77.91%** line coverage, red on the SimpleCov floor and on nothing else, since the suites that raise it are the tests tip's; every other gate is green there. The same caveat about `rubocop` that phases 1 and 2 recorded: run through `rake` from a worktree nested under the parent checkout's `.claude/` it inspects 9 files; @@ -115,9 +115,10 @@ on `BufferedSource`, and `store_take`/`store_peek`/`copy_to` reshaped under `Met The matrix set (`test:gems gates:gemspec_audit gates:require_allowlist gates:clean_bundle gates:single_instance`) is green on **3.2.11**, **3.3.12** and **3.4.10** at the tests tip, each -with its own lockfile resolved fresh — 774 runs / 4,630 assertions on each, 99.95% line coverage — -and on the code tip `test:gems` on 3.2.11 is 530 runs, 0 failures, 78.83%, the floor again the only -red. The five interpreter-sensitive assertions Task 15 Step 6 names are green on every row: +with its own lockfile resolved fresh — 783 runs / 4,669 assertions on each, 99.95% line coverage — +and on the code tip `test:gems` on 3.2.11 is 530 runs, 0 failures, 78.74%, the floor again the only +red. Every number in this section is the round-1 tip's, re-measured after review round 0's fixes +(the round-0 tip's were 774 / 4,630, 2,218 / 2,219, 262 methods, 78.00% and 78.83%). The five interpreter-sensitive assertions Task 15 Step 6 names are green on every row: the `StringIO`-backed BINARY read (`buffered_source_test.rb`, `Bridge`, "a StringIO-backed source returns BINARY on every interpreter in the matrix"), the frozen-chunk ingress retag (`Over`, "over survives a body that yields frozen literals"), the `Enumerator` `ensure` asymmetry (`Over`, "closing @@ -129,10 +130,11 @@ the block form of `module_eval` resolves a constant in the block's own lexical s interpreters, so the `Dexpace::IO` shadow is probed with string evals (`io_test.rb`). The surface manifest `test/fixtures/surface/dexpace-core.txt` grew from 318 to 367 lines through -`bundle exec rake surface:regenerate`, once, after Task 14; the 49 added lines were read one by one -against the plan's Task 15 Step 3 list and are exactly it, with two differences stated below: -`Dexpace::IO::TeeSink#clear_tap` is absent (deviation 1) and -`Dexpace::IO::BufferedSource.__dexpace_view` is present (deviation 7). `Dexpace::IO::Buffer#` carries +`bundle exec rake surface:regenerate`, once, after Task 14, and is 366 since review round 0; the 48 +added lines were read one by one against the plan's Task 15 Step 3 list and are exactly it, with +two differences stated below: `Dexpace::IO::TeeSink#clear_tap` is absent (deviation 1) and +`Dexpace::IO::BufferedSource.__dexpace_view`, present at the round-0 tip, is absent since the view +constructor became a private class method (deviation 7). `Dexpace::IO::Buffer#` carries four names and `TypedReads#` fifteen, which is P3-8's whole reason for keeping the two vocabularies public. No `private_constant` appears: `BufferedSource::View`, `TypedReads::READ_SEGMENT_BYTES` and `TypedWrites::WRITE_ALL_SEGMENT_BYTES` are absent, and the three RBS interfaces carry no runtime @@ -167,6 +169,17 @@ The stage-9 failure is the transitive-invalidation test that Task 10's protocol (deviation 3); the shape is the plan's ladder, with the plan's pre-amendment counts replaced by the built tree's. +Review round 0 (2026-09-15) found that a view's fill asked its parent to buffer `behind + count`, so +`#read_into`, `#readpartial` and `#each` on a `#peek` or `#slice` blocked until the whole count had +arrived where the root returned at once (R0-1; deviation 18). The fix landed on the code branch and +nine tests on the tests branch; the five that distinguish the two shapes were run red against the +pre-fix `ensure_buffered(behind + want)` on 4.0.6 and green on 4.0.6 and 3.2.11 after it: + +| Fix not yet applied | Guard | What it said | +|---|---|---| +| the parent-side view fill back to `ensure_buffered(behind + want)` | `typed_reads_test.rb`, `ViewFills` | four failures: `Expected: 10 Actual: 13` (`#read_into` on a peek took the second chunk too), `"0123456789abc"` for `#readpartial(100)`, `"abcd"` for `#each`'s first chunk, and `Expected: 1 Actual: 2` on the slice past the buffered bytes; the two `#read(n)`/`#read_exactly` rows stayed green, as Ruby's contract says they must | +| the same | `buffered_source_test.rb`, `Views`, "a view over a live pipe returns what has arrived, like the root" | `Expected # to not be closed?` — the peek's `read_into(count: 100)` over ten bytes returned only after the two-second watchdog closed the writer | + ## Audit groups run The phase-start pair first, at implementation: `--origin note --brief` returns 52 note entries @@ -216,7 +229,7 @@ accepted to rejected (item 2). outer view into the root's store — or, for a `.of_bytes` root, pulled fresh bytes from the root's enumerator after the root had been closed. `IO-22`'s "every outstanding slice derived from it" reaches a slice of a slice, so `#dexpace_invalidate` now releases the view's own views, and - `#dexpace_ensure_buffered`/`#dexpace_window_copy` call `#ensure_readable` first, so a closed + `#dexpace_fill_beyond`/`#dexpace_window_copy` call `#ensure_readable` first, so a closed stream-backed parent or an invalidated intermediate serves nothing to any view. Found by a test the plan did not have ("closing the parent invalidates a slice of a slice, transitively"), run red before the change. @@ -244,16 +257,22 @@ accepted to rejected (item 2). Ruby names the first I/O signatures needed. The fixture `test/fixtures/gates/rbs_surface/gems/ fixture/sig/io_superclasses.rbs` and a pinning test in `test/gates/rbs_surface_test.rb` keep them admitted; the gate was watched red on all three before and green after. -7. **`BufferedSource.__dexpace_view` is a public singleton method with an RBS declaration and a - YARD block**, where the design calls the view constructor "internal — no RBS signature, no YARD - block". Strict Steep treats an undeclared `def self.` as a diagnostic that fails the gate, and the - caller (`TypedReads#build_view`) is outside the class, so a private class method cannot serve. It - appears in the surface manifest under its underscore name, its YARD block says it is not public - API, and it is documented nowhere else. +7. **`BufferedSource.__dexpace_view` is a `private_class_method` with a `private def self.` RBS + declaration, reached through `send`**, where the plan's Task 10 fence defined it as a public + singleton method called by name and the design calls the view constructor "internal — no RBS + signature, no YARD block". Strict Steep treats an undeclared `def self.` as a diagnostic that + fails the gate, so the declaration exists, and `tools/sig_diff.rb` reads its `private` modifier + and keeps it out of the `NFR-4` diff; `private_class_method` keeps it out of the surface + manifest; its comment is not a YARD block under `.yardopts`'s `--no-private`; and the caller, + `TypedReads#build_view`, outside the class, reaches it with `send` as phase 1's `Headers#==` + reaches `#values` — the hole design §4's P8 records, used from inside core. At the round-0 tip it + was public with a manifest row; review round 0's R0-3 made it private before the first tag locks + the underscore name, and `buffered_source_test.rb`, `Views` pins the absence. 8. **Test files nest a class per behaviour group** for the 100-line cap (phase 2's deviation 4): `typed_reads_test.rb` (`Rejections`, `Typed`, `Charsets`, `Ceiling`, `Lines`, `Bridge`, `Each`, - `Views`, `ViewLifecycle`, `Includer`), `buffered_source_test.rb` (`Bridge`, `Over`, `Close`, - `Threads`, `Counts`), `typed_writes_test.rb` (`WriteFrom`, `Pump`, `Charsets`, `Includer`), + `Views`, `ViewFills`, `ViewLifecycle`, `Includer`), `buffered_source_test.rb` (`Bridge`, `Over`, + `Close`, `Threads`, `Counts`, `Views`), `typed_writes_test.rb` (`WriteFrom`, `Pump`, `Charsets`, + `Includer`), `buffer_test.rb` (`Snapshot`, `CopyTo`, `Close`), `buffered_sink_test.rb` (`Staging`, `Close`), `tee_sink_test.rb` (`Failure`, `Lifecycle`, `Vocabulary`), each over a shared factory module. 9. **`store_take` and `store_peek` are reshaped under `Metrics/AbcSize`**: `#take_from_head` is @@ -271,8 +290,10 @@ accepted to rejected (item 2). `error/stream_error`, `error/invalid_argument_error` and `error/closed_error` where each is raised — are present, so every file names what it raises; the require audit sees only `require_relative`. -12. **Run counts exceed the plan's.** The `IO` suites are 245 runs where the plan's built-tree run - was 209 for the whole tree, because the nested classes carry extra cases the plan did not have +12. **Run counts exceed the plan's.** The `IO` suites — `io_test.rb` and the six files under + `test/dexpace/io/` — are 240 runs since review round 0 (231 before its nine; the 245 first + written here was not this seven-file count) where the plan's built-tree run was 209 for the + whole tree, because the nested classes carry extra cases the plan did not have (the readability checks, the count-passing guards on the vocabulary's own side, the pipe-end sink, the transitive invalidation, the consumer-side shadow probe); the plan's step-level expected counts ("PASS, 14 tests", "PASS, 3 tests") are therefore not matched and were not @@ -287,8 +308,10 @@ accepted to rejected (item 2). the one written first used `Dexpace.module_eval { IO }`, which resolves `IO` in the block's own lexical scope — to `::IO` — on 3.2.11, 3.4.10 and 4.0.6 alike, and was caught by the floor run after Task 12 (the first run of that file on any row after Task 4). `module_eval("IO", __FILE__, - __LINE__)` and a consumer class's `class_eval("…is_a?(IO)…")` state exactly the design's - finding, both halves. + __LINE__)` and a consumer class's `class_eval` state exactly the design's finding, both + halves. Since review round 0 (R0-4) each probe evals a lambda, `->(x) { x.is_a?(IO) }`, in the + receiver's scope and hands it the pipe end the test creates and closes in its `ensure`, where + the first shape created an `IO.pipe` inside the eval'd string and never closed it. 15. **`Buffer#snapshot` reuses `TypedReads#guard_materialization!`** rather than a private `#ensure_snapshot_within_ceiling` of its own, so the six guarded sites share one message form. 16. **The entry file's nine `require_relative`s are one block appended after phase 2's**, in @@ -298,6 +321,24 @@ accepted to rejected (item 2). on the forward references to `Dexpace::IO::BufferedSource` and `Dexpace::IO::Buffer` that the plan's own signatures carry; the plan's per-task "then `rake rbs:validate steep`" cannot pass there. Steep was green at every task where `rbs:validate` was. +18. **A view's fill is one fill past its position, never a loop to the count** (review round 0, + R0-1). The plan's Task 10 fence for `#fill_from_parent` called + `parent.dexpace_ensure_buffered(behind + want)`, whose loop blocks until the whole count is + buffered — the shape the same task's 2026-09-13 amendment forbids for the root ("must **not** + become `ensure_buffered(count)`") and the design's R2 ("fills ONCE") and R4 ("keeps the bridge + claim true of views") rule out. Measured: a peek over an `IO.pipe` holding ten bytes with the + writer open hung on `read_into(count: 100)` and `readpartial(100)` where the root returned the + ten at once. As built the parent-side entry point is `#dexpace_fill_beyond(behind, want)`, + replacing `#dexpace_ensure_buffered`: fill until at least one byte past `behind` is buffered or + the upstream is done, each fill asking the upstream for the rest of what the view wants, and + the view takes what is there up to `want`. `#read(n)` and `#read_exactly(n)` on a view still + fill to `n` through the view's own `#ensure_buffered` loop, which is Ruby's contract. The + protected declaration in `sig/dexpace/io/typed_reads.rbs` follows; guard run red above. +19. **Two `.rbs` declarations are wrapped** (R0-5): `buffered_source.rbs`'s view constructor and + `#initialize` lines were 127 and 105 columns against the plan's 100-column rule, which RuboCop + does not read for `.rbs`; both now take the multi-line parameter shape phase 2's + `registry.rbs` and `operation.rbs` use. `async_transport.rbs:17`, 105 columns since phase 2, is + not this phase's and is left as it is. ## Findings routed @@ -311,6 +352,15 @@ accepted to rejected (item 2). not a finding against any gate; recorded in the sig comments where a later phase meets it. - **`rake rubocop` is vacuous in a worktree nested under the parent checkout's `.claude/`** — already on phase 10's inbound list from phase 1; the honest run is recorded above. +- **Review round 0's five findings** (2026-09-15) were each in material this phase may write and + were fixed in place, on the branch that owns the file: R0-1 (a view blocked to the count; deviation + 18), R0-3 (the view constructor was public surface; deviation 7) and R0-5 (two `.rbs` lines over + 100 columns; deviation 19) on the code branch, R0-4 (the shadow probes leaked a pipe pair each; + deviation 14) on the tests branch, and R0-2 on the docs branch: `docs/sdk-documentation/io.md`'s + views example claimed the final `slice.read` raised `Dexpace::ClosedError` when the built code + returned `""`, because the slice had already pulled its whole window and P3-5's "behind" failure + needs a byte still unread; the example now leaves one, and was re-run on 4.0.6 and 3.2.11. Nothing + was routed elsewhere. ## Postponed work diff --git a/docs/work/mvp/phase3/phase3a/2026-09-08-phase3a-io-contracts-design.md b/docs/work/mvp/phase3/phase3a/2026-09-08-phase3a-io-contracts-design.md index 0f5314b..2c45aa6 100644 --- a/docs/work/mvp/phase3/phase3a/2026-09-08-phase3a-io-contracts-design.md +++ b/docs/work/mvp/phase3/phase3a/2026-09-08-phase3a-io-contracts-design.md @@ -1136,7 +1136,7 @@ consolidation into design §10: |---|---|---|---| | P3-13 | `#read` and `#readpartial` leave `outbuf` tagged `Encoding::BINARY` whatever tag it arrived with, where `::IO#read` preserves the destination's tag | `IO-16`; design §3.1's BINARY rule; verified fact 11 | The bridge's contract is "Ruby's semantics", and on this one point Ruby's own readers disagree with each other and across this port's floor: `::IO#read(n, buf)` preserves a UTF-8 destination's tag on 3.2.11, 3.4.10 and 4.0.6, while `StringIO#read(n, buf)` gives ASCII-8BIT on 3.2.11 and UTF-8 from 3.4.10. Pinning BINARY is the one answer identical on every matrix row and behind every backing stream. `IO.copy_stream` never reads the destination's tag, so R4's bridge claim is unaffected — asserted end to end in `buffered_source_test.rb` | -Five rows and two statements above read slightly differently against the source; the difference +Five rows and three statements above read slightly differently against the source; the difference is recorded here rather than by rewriting the text it corrects. The checklist's "Deviations from the plan" is the itemised list. @@ -1144,18 +1144,30 @@ the plan" is the itemised list. the keep-or-drop decision, and it was dropped, because phase 3b satisfies `BODY-18` by building a fresh tee per write — forced by `TeeSink` binding its primary at construction — so the method had no caller anywhere in core while being `NFR-4`-locked surface. The 3a→3b contract table above - and P3-8's list name it; the surface manifest, the RBS signature and the test do not. The row - gains one name it did not list: `BufferedSource.__dexpace_view`, the view constructor, is a public - singleton method with an RBS declaration and a YARD block (which says it is not public API), - because strict Steep refuses an undeclared `def self.` and the caller lives in `TypedReads`, - outside the class; the object model's "internal — no RBS signature, no YARD block" sentence does - not hold and is corrected by this note. + and P3-8's list name it; the surface manifest, the RBS signature and the test do not. The view + constructor, `BufferedSource.__dexpace_view`, is internal as the object model says — a + `private_class_method` with a `private def self.` RBS declaration, in neither the surface + manifest nor the `NFR-4` diff — with one correction to that sentence: the declaration exists, + because strict Steep refuses an undeclared `def self.`, and its caller `TypedReads#build_view`, + outside the class, reaches it through `send` as `Headers#==` reaches `#values`. At the round-0 + tip it was a declared public singleton method with a manifest row; review round 0's R0-3 made it + private before the first tag locks the underscore name (checklist deviation 7). - **P3-5, as built.** Invalidation cascades: a view's `#dexpace_invalidate` releases the view's own views, and the two protected entry points a view drives its parent through check the parent's readability first, so a slice of a slice is invalidated when the root closes and an invalidated intermediate serves nothing. The plan's single-flag protocol let an inner view pull fresh bytes through an invalidated outer one from a closed `.of_bytes` root; `IO-22`'s "every outstanding slice derived from it" reaches transitively. The retention rule is unchanged. +- **The view fill, as built (review round 0).** The plan's `#fill_from_parent` fence asked the + parent for `dexpace_ensure_buffered(behind + want)`, whose loop blocks until the whole count is + buffered, so a view's `#read_into`, `#readpartial` and `#each` waited for `count` bytes where the + root returns what its one fill brought — a peek over a pipe holding ten bytes hung on + `read_into(count: 100)` (R0-1). R2's "fills ONCE" and R4's "keeps the bridge claim true of views" + are the rule, so the parent-side entry point is `#dexpace_fill_beyond(behind, want)`: fill until + one byte past the view's position is buffered or the upstream is done, asking the upstream for + the view's own count each time, and the view takes what is there. `#read(n)` on a view blocks to + `n` only through the view's own `#ensure_buffered` loop. The retention rule is unchanged + (checklist deviation 18; guard run red there). - **P3-7, as built.** The widening moved one phase-2 cop case from accepted to rejected — a bare `Thread` inside `Dexpace::Transport`, accepted in phase 2 because nothing reopens that name, and an offense under the one-segment watch by the rule's own words. A tightening, recorded because