pax_global_header00006660000000000000000000000064151746072060014522gustar00rootroot0000000000000052 comment=81522e27657d544bb2ef5d9cca90585f3366bb7a socketry-io-stream-81522e2/000077500000000000000000000000001517460720600155355ustar00rootroot00000000000000socketry-io-stream-81522e2/.editorconfig000066400000000000000000000001511517460720600202070ustar00rootroot00000000000000root = true [*] indent_style = tab indent_size = 2 [*.{yml,yaml}] indent_style = space indent_size = 2 socketry-io-stream-81522e2/.github/000077500000000000000000000000001517460720600170755ustar00rootroot00000000000000socketry-io-stream-81522e2/.github/copilot-instructions.md000066400000000000000000000016431517460720600236360ustar00rootroot00000000000000# GitHub Copilot Instructions ## Use Agent Context When working on this project, consult the `agents.md` file for project-specific guidelines, architecture decisions, and development patterns. This file contains curated information that will help you make better decisions aligned with the project's goals and standards. If the file does not exist, you will need to install it, by running the following command: ```bash $ bundle install $ bundle exec bake agent:context:install ``` This command will set up the necessary context files that help you understand the project structure, dependencies, and conventions. ## Ignoring Files The `.gitignore` file is split into two sections, separated by a blank line. The first section is automatically generated, while the second section is user controlled. While working on pull requests, you should not add unrelated changes to the `.gitignore` file as part of the pull request. socketry-io-stream-81522e2/.github/workflows/000077500000000000000000000000001517460720600211325ustar00rootroot00000000000000socketry-io-stream-81522e2/.github/workflows/documentation-coverage.yaml000066400000000000000000000006511517460720600264620ustar00rootroot00000000000000name: Documentation Coverage on: [push, pull_request] permissions: contents: read env: COVERAGE: PartialSummary jobs: validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: ruby bundler-cache: true - name: Validate coverage timeout-minutes: 5 run: bundle exec bake decode:index:coverage lib socketry-io-stream-81522e2/.github/workflows/documentation.yaml000066400000000000000000000021311517460720600246640ustar00rootroot00000000000000name: Documentation on: push: branches: - main # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages: permissions: contents: read pages: write id-token: write # Allow one concurrent deployment: concurrency: group: "pages" cancel-in-progress: true env: BUNDLE_WITH: maintenance jobs: generate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: ruby bundler-cache: true - name: Installing packages run: sudo apt-get install wget - name: Generate documentation timeout-minutes: 5 run: bundle exec bake utopia:project:static --force no - name: Upload documentation artifact uses: actions/upload-pages-artifact@v4 with: path: docs deploy: runs-on: ubuntu-latest environment: name: github-pages url: ${{steps.deployment.outputs.page_url}} needs: generate steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 socketry-io-stream-81522e2/.github/workflows/rubocop.yaml000066400000000000000000000005321517460720600234670ustar00rootroot00000000000000name: RuboCop on: [push, pull_request] permissions: contents: read jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: ruby bundler-cache: true - name: Run RuboCop timeout-minutes: 10 run: bundle exec rubocop socketry-io-stream-81522e2/.github/workflows/test-coverage.yaml000066400000000000000000000022431517460720600245670ustar00rootroot00000000000000name: Test Coverage on: [push, pull_request] permissions: contents: read env: COVERAGE: PartialSummary jobs: test: name: ${{matrix.ruby}} on ${{matrix.os}} runs-on: ${{matrix.os}}-latest strategy: matrix: os: - ubuntu - macos ruby: - 3.3 - 3.4 - ruby steps: - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{matrix.ruby}} bundler-cache: true - name: Run tests timeout-minutes: 5 run: bundle exec bake test - uses: actions/upload-artifact@v5 with: include-hidden-files: true if-no-files-found: error name: coverage-${{matrix.os}}-${{matrix.ruby}} path: .covered.db validate: needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: ruby bundler-cache: true - uses: actions/download-artifact@v6 - name: Validate coverage timeout-minutes: 5 run: bundle exec bake covered:validate --paths */.covered.db \; socketry-io-stream-81522e2/.github/workflows/test-external.yaml000066400000000000000000000011101517460720600246060ustar00rootroot00000000000000name: Test External on: [push, pull_request] permissions: contents: read jobs: test: name: ${{matrix.ruby}} on ${{matrix.os}} runs-on: ${{matrix.os}}-latest strategy: matrix: os: - ubuntu - macos ruby: - "3.3" - "3.4" - "4.0" steps: - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{matrix.ruby}} bundler-cache: true - name: Run tests timeout-minutes: 10 run: bundle exec bake test:external socketry-io-stream-81522e2/.github/workflows/test.yaml000066400000000000000000000016261517460720600230020ustar00rootroot00000000000000name: Test on: [push, pull_request] permissions: contents: read jobs: test: name: ${{matrix.ruby}} on ${{matrix.os}} runs-on: ${{matrix.os}}-latest continue-on-error: ${{matrix.experimental}} strategy: matrix: os: - ubuntu - macos ruby: - "3.3" - "3.4" - "4.0" experimental: [false] include: - os: ubuntu ruby: truffleruby experimental: true - os: ubuntu ruby: jruby experimental: true - os: ubuntu ruby: head experimental: true steps: - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{matrix.ruby}} bundler-cache: true - name: Run tests timeout-minutes: 10 run: bundle exec bake test socketry-io-stream-81522e2/.gitignore000066400000000000000000000001071517460720600175230ustar00rootroot00000000000000/agents.md /.context /.bundle /pkg /gems.locked /.covered.db /external socketry-io-stream-81522e2/.rubocop.yml000066400000000000000000000035401517460720600200110ustar00rootroot00000000000000plugins: - rubocop-md - rubocop-socketry AllCops: DisabledByDefault: true # Socketry specific rules: Layout/ConsistentBlankLineIndentation: Enabled: true Layout/BlockDelimiterSpacing: Enabled: true Style/GlobalExceptionVariables: Enabled: true # General Layout rules: Layout/IndentationStyle: Enabled: true EnforcedStyle: tabs Layout/InitialIndentation: Enabled: true Layout/IndentationWidth: Enabled: true Width: 1 Layout/IndentationConsistency: Enabled: true EnforcedStyle: normal Layout/BlockAlignment: Enabled: true Layout/EndAlignment: Enabled: true EnforcedStyleAlignWith: start_of_line Layout/BeginEndAlignment: Enabled: true EnforcedStyleAlignWith: start_of_line Layout/RescueEnsureAlignment: Enabled: true Layout/ElseAlignment: Enabled: true Layout/DefEndAlignment: Enabled: true Layout/CaseIndentation: Enabled: true EnforcedStyle: end Layout/CommentIndentation: Enabled: true Layout/FirstHashElementIndentation: Enabled: true EnforcedStyle: consistent Layout/EmptyLinesAroundClassBody: Enabled: true Layout/EmptyLinesAroundModuleBody: Enabled: true Layout/EmptyLineAfterMagicComment: Enabled: true Layout/SpaceInsideBlockBraces: Enabled: true EnforcedStyle: no_space SpaceBeforeBlockParameters: false Layout/SpaceAroundBlockParameters: Enabled: true EnforcedStyleInsidePipes: no_space Layout/FirstArrayElementIndentation: Enabled: true EnforcedStyle: consistent Layout/ArrayAlignment: Enabled: true EnforcedStyle: with_fixed_indentation Layout/FirstArgumentIndentation: Enabled: true EnforcedStyle: consistent Layout/ArgumentAlignment: Enabled: true EnforcedStyle: with_fixed_indentation Layout/ClosingParenthesisIndentation: Enabled: true Style/FrozenStringLiteralComment: Enabled: true Style/StringLiterals: Enabled: true EnforcedStyle: double_quotes socketry-io-stream-81522e2/bake.rb000066400000000000000000000010601517460720600167610ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025-2026, by Samuel Williams. # Update the project documentation with the new version number. # # @parameter version [String] The new version number. def after_gem_release_version_increment(version) context["releases:update"].call(version) context["utopia:project:update"].call end # Create a GitHub release for the given tag. # # @parameter tag [String] The tag to create a release for. def after_gem_release(tag:, **options) context["releases:github:release"].call(tag) end socketry-io-stream-81522e2/config/000077500000000000000000000000001517460720600170025ustar00rootroot00000000000000socketry-io-stream-81522e2/config/external.yaml000066400000000000000000000007251517460720600215140ustar00rootroot00000000000000async-http: url: https://github.com/socketry/async-http.git command: bundle exec sus protocol-websocket: url: https://github.com/socketry/protocol-websocket.git command: bundle exec sus async-websocket: url: https://github.com/socketry/async-websocket.git command: bundle exec sus falcon: url: https://github.com/socketry/falcon.git command: bundle exec sus protocol-rack: url: https://github.com/socketry/protocol-rack.git command: bundle exec sus socketry-io-stream-81522e2/config/sus.rb000066400000000000000000000003031517460720600201350ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024-2026, by Samuel Williams. require "covered/sus" include Covered::Sus require "async/safe" Async::Safe.enable! socketry-io-stream-81522e2/context/000077500000000000000000000000001517460720600172215ustar00rootroot00000000000000socketry-io-stream-81522e2/context/getting-started.md000066400000000000000000000065221517460720600226550ustar00rootroot00000000000000# Getting Started This guide explains how to use `io-stream` to add efficient buffering to Ruby IO objects. ## Overview `io-stream` provides a buffered stream wrapper for any IO-like object in Ruby. It wraps standard Ruby IO instances (files, sockets, pipes) and adds buffering for both reading and writing operations, significantly improving performance for applications that perform many small reads or writes. ## Installation Add the gem to your project: ~~~ bash $ bundle add io-stream ~~~ ## Core Concepts ### Buffered Streams `io-stream` provides buffering through the {IO::Stream::Buffered} class, which wraps any IO object. Buffering reduces the number of system calls by accumulating data in memory before actually reading from or writing to the underlying IO. ### Read and Write Buffers The stream maintains separate buffers for reading and writing: - **Read buffer**: Accumulates data from the underlying IO, allowing multiple small reads without system calls - **Write buffer**: Accumulates data to write, flushing to the underlying IO only when the buffer is full or explicitly flushed ## Usage ### Wrapping an IO Object You can wrap any IO-like object using {IO::Stream}: ~~~ ruby require 'io/stream' # Wrap a file file = File.open("data.txt", "w+") stream = IO::Stream(file) # Wrap a socket require 'socket' socket = TCPSocket.new("example.com", 80) stream = IO::Stream(socket) ~~~ ### Opening Files Directly You can also open files directly as buffered streams: ~~~ ruby require 'io/stream' # Open a file for reading stream = IO::Stream::Buffered.open("data.txt", "r") data = stream.read stream.close # Open with a block (auto-closes) IO::Stream::Buffered.open("data.txt", "w") do |stream| stream.write("Hello, World!") stream.flush end ~~~ ### Reading Data The {IO::Stream::Readable} module provides various methods for reading: ~~~ ruby require 'io/stream' IO::Stream::Buffered.open("data.txt", "r") do |stream| # Read entire stream content = stream.read # Read specific number of bytes chunk = stream.read(1024) # Read a line line = stream.gets # Read all lines lines = stream.readlines # Check for end of stream if stream.eof? puts "Reached end of file" end end ~~~ ### Writing Data The {IO::Stream::Writable} module provides methods for writing: ~~~ ruby require 'io/stream' IO::Stream::Buffered.open("output.txt", "w") do |stream| # Write data (buffered) stream.write("Hello, ") stream.write("World!") # Write with automatic newline stream.puts("This is a line") # Flush buffer to ensure data is written stream.flush end ~~~ ## Important Behaviors ### Automatic Flushing The write buffer automatically flushes when: - The buffer size reaches the minimum write size (default: 64KB). - You call {IO::Stream::Writable#puts} (always flushes immediately). - You call {IO::Stream::Writable#flush} explicitly. - The stream is closed. ### Manual Flushing For applications that need precise control over when data is written: ~~~ ruby stream.write("Important data") stream.flush # Ensure data is written immediately ~~~ ### Buffer Sizes You can customize buffer sizes when creating streams: ~~~ ruby # Smaller buffer for interactive applications stream = IO::Stream::Buffered.new(io, minimum_write_size: 4096) # Larger buffer for bulk operations stream = IO::Stream::Buffered.new(io, minimum_write_size: 256 * 1024) ~~~ socketry-io-stream-81522e2/context/high-performance-io.md000066400000000000000000000142551517460720600233750ustar00rootroot00000000000000# High Performance IO This guide explains how to achieve optimal performance when using `io-stream` by understanding and controlling flush behavior. ## Overview The key to high-performance IO with `io-stream` is understanding when and how to flush your write buffer. Improper flush timing can significantly impact throughput, latency, and CPU usage. This guide helps you choose the right buffering strategy for your application. ## Why Buffering Matters Every write to an underlying IO object (file, socket, pipe) involves a system call, which has overhead: - **Context switching**: Transferring control between userspace and kernel space. - **System call overhead**: The cost of invoking kernel functions. - **Network packet overhead**: For sockets, each small write may trigger a separate packet. Buffering solves this by accumulating data in memory and performing larger, less frequent writes. However, buffering introduces latency - data sits in memory until flushed. Use buffering when you need: - **High throughput**: Maximize data transfer rate for bulk operations. - **Reduced CPU usage**: Minimize system call overhead when writing many small pieces. - **Efficient network utilization**: Avoid sending many tiny packets. ## The Flush/Throughput Tradeoff There's a fundamental tradeoff between responsiveness and throughput: ```mermaid graph LR A[Immediate Flush] -->|Low Latency| B[Responsive] A -->|Many System Calls| C[Lower Throughput] D[Delayed Flush] -->|Higher Latency| E[Buffered] D -->|Fewer System Calls| F[Higher Throughput] ``` **Immediate flushing** (after every write): - ✅ Data is sent immediately - low latency. - ✅ Simple mental model - predictable behavior. - ❌ High system call overhead. - ❌ Lower maximum throughput. - ❌ More CPU usage. - ❌ Network inefficiency (many small packets). **Buffered flushing** (accumulate before sending): - ✅ Fewer system calls - higher throughput. - ✅ Better CPU efficiency. - ✅ More efficient network packet utilization. - ❌ Data is delayed - higher latency. - ❌ Requires careful flush management. ## Automatic Flush Behavior `io-stream` automatically flushes in these situations: ~~~ ruby # 1. Buffer reaches minimum_write_size (default: 64KB) stream.write("x" * 65536) # Automatically flushes # 2. Using puts() always flushes stream.puts("This is flushed immediately") # 3. Closing the stream stream.close # Flushes any remaining data ~~~ ## Choosing Your Flush Strategy ### Strategy 1: Let Automatic Flushing Handle It Best for: Bulk data transfer, file processing, log writing. ~~~ ruby require 'io/stream' # Default behavior - automatic flush at 64KB stream = IO::Stream::Buffered.open("large_file.dat", "w") # Write lots of data 1000.times do |i| stream.write("Record #{i}\n" * 1000) end stream.close # Final flush on close ~~~ **When to use:** - Writing large amounts of data continuously. - Throughput is more important than latency. - You don't need interactive feedback. ### Strategy 2: Manual Flush at Logical Boundaries Best for: Request/response protocols, transaction processing, structured logging. ~~~ ruby require 'io/stream' require 'socket' socket = TCPSocket.new("example.com", 80) stream = IO::Stream(socket) # Build complete HTTP request stream.write("GET / HTTP/1.1\r\n") stream.write("Host: example.com\r\n") stream.write("Connection: close\r\n") stream.write("\r\n") # Flush after complete request stream.flush # Send request as one operation ~~~ **When to use:** - Message-based protocols (HTTP, Redis, etc.) - You need to send complete "units" of data - Each logical operation should complete atomically - Balance between throughput and responsiveness ### Strategy 3: Immediate Flush for Interactive Applications Best for: Chat applications, streaming responses, real-time dashboards. ~~~ ruby require 'io/stream' # Use smaller buffer for more frequent automatic flushes stream = IO::Stream::Buffered.new( socket, minimum_write_size: 512 # Smaller buffer = more responsive ) # Or flush after every message stream.write(message) stream.flush # Ensure immediate delivery ~~~ **When to use:** - Real-time user interaction required. - Low latency is critical. - Data arrives in small, discrete chunks. ### Strategy 4: Time-Based Flushing Best for: Streaming data, progress updates, monitoring ~~~ ruby require 'io/stream' stream = IO::Stream::Buffered.open("stream.log", "w") last_flush = Time.now loop do stream.write(generate_log_entry) # Flush every second or when buffer is large if Time.now - last_flush > 1.0 stream.flush last_flush = Time.now end end ~~~ **When to use:** - Ensuring regular progress visibility. - Protecting against data loss (periodic flush to disk). - Streaming applications with real-time monitoring. ### Strategy 5: Readiness based flushing Best for: interactive protocols, terminal applications, chat servers. ~~~ ruby require 'io/stream' stream = IO::Stream::Buffered.new(socket, minimum_write_size: 1024) loop do # Blocking read from a queue of messages to send: chunk = queue.pop stream.write(chunk) if queue.empty? # Flush when we are likely to block on the queue: stream.flush end end ~~~ **When to use:** - When you have unpredictable message arrival patterns. - When you want to ensure the lowest possible latency while still benefiting from buffering when messages arrive in bursts. ## Buffer Size Configuration The `minimum_write_size` parameter controls when automatic flushing occurs: ~~~ ruby # Very small buffer - more responsive, lower throughput stream = IO::Stream::Buffered.new(io, minimum_write_size: 1024) # Default - balanced (64KB) stream = IO::Stream::Buffered.new(io) # Large buffer - maximum throughput, higher latency stream = IO::Stream::Buffered.new(io, minimum_write_size: 512 * 1024) ~~~ ### Choosing Buffer Size **Small buffers (1-8KB):** - Interactive protocols (terminal, chat). - Real-time data visualization. - Acceptable: Lower throughput. **Medium buffers (8-64KB):** - Web servers (default is good). - Application servers. - Database connections. - Balance of throughput and responsiveness. **Large buffers (64KB-1MB):** - File processing. - Bulk data transfer. - Video encoding. - Logging systems. - Only latency-insensitive applications. socketry-io-stream-81522e2/context/index.yaml000066400000000000000000000014071517460720600212160ustar00rootroot00000000000000# Automatically generated context index for Utopia::Project guides. # Do not edit then files in this directory directly, instead edit the guides and then run `bake utopia:project:agent:context:update`. --- description: Provides a generic stream wrapper for IO instances. metadata: documentation_uri: https://socketry.github.io/io-stream/ source_code_uri: https://github.com/socketry/io-stream.git files: - path: getting-started.md title: Getting Started description: This guide explains how to use `io-stream` to add efficient buffering to Ruby IO objects. - path: high-performance-io.md title: High Performance IO description: This guide explains how to achieve optimal performance when using `io-stream` by understanding and controlling flush behavior. socketry-io-stream-81522e2/examples/000077500000000000000000000000001517460720600173535ustar00rootroot00000000000000socketry-io-stream-81522e2/examples/async-safe/000077500000000000000000000000001517460720600214045ustar00rootroot00000000000000socketry-io-stream-81522e2/examples/async-safe/test.rb000066400000000000000000000010261517460720600227070ustar00rootroot00000000000000#!/usr/bin/env ruby # frozen_string_literal: true # Released under the MIT License. # Copyright, 2025-2026, by Samuel Williams. require_relative "../../lib/io/stream" require "async" require "async/safe" # Enable concurrent access detection: Async::Safe.enable! # Create a simple stream with some data: input, output = IO.pipe stream = IO::Stream::Buffered.new(input) output.write("Hello") Async do |task| task.async(transient: true) do data = stream.read(10) end # This should raise ViolationError: stream.read(10) end socketry-io-stream-81522e2/examples/consistency/000077500000000000000000000000001517460720600217145ustar00rootroot00000000000000socketry-io-stream-81522e2/examples/consistency/async_close_while_reading.rb000077500000000000000000000011641517460720600274310ustar00rootroot00000000000000#!/usr/bin/env ruby # frozen_string_literal: true # Released under the MIT License. # Copyright, 2024-2025, by Samuel Williams. require "bundler/inline" gemfile do source "https://rubygems.org" gem "async" end require "socket" def close_while_reading(io) thread = Thread.new do Thread.current.report_on_exception = false io.wait_readable end # Wait until the thread is blocked on read: Thread.pass until thread.status == "sleep" Async do io.close end thread.join end begin client, server = Socket.pair(:UNIX, :STREAM) close_while_reading(client) rescue => error $stderr.puts error.full_message end socketry-io-stream-81522e2/examples/consistency/close_while_reading.rb000077500000000000000000000021761517460720600262400ustar00rootroot00000000000000#!/usr/bin/env ruby # frozen_string_literal: true # Released under the MIT License. # Copyright, 2024-2025, by Samuel Williams. require "bundler/inline" gemfile do source "https://rubygems.org" gem "localhost" end require "socket" require "openssl" require "localhost" def close_while_reading(io) thread = Thread.new do io.to_io.wait_readable end # Wait until the thread is blocked on read: Thread.pass until thread.status == "sleep" io.close return thread.value end begin client, server = Socket.pair(:UNIX, :STREAM) close_while_reading(client) rescue => error $stderr.puts error.full_message end begin authority = Localhost::Authority.fetch client, server = Socket.pair(:UNIX, :STREAM) ssl_server = OpenSSL::SSL::SSLSocket.new(server, authority.server_context) ssl_server.sync_close = true ssl_client = OpenSSL::SSL::SSLSocket.new(client, authority.client_context) ssl_client.sync_close = true # If this is not set, `io.read` above will hang which is also a bit odd. Thread.new{ssl_server.accept} ssl_client.connect close_while_reading(ssl_client) rescue => error $stderr.puts error.full_message end socketry-io-stream-81522e2/examples/consistency/read_after_close.rb000077500000000000000000000017241517460720600255310ustar00rootroot00000000000000#!/usr/bin/env ruby # frozen_string_literal: true # Released under the MIT License. # Copyright, 2024-2025, by Samuel Williams. require "bundler/inline" gemfile do source "https://rubygems.org" gem "localhost" end require "socket" require "openssl" require "localhost" def read_after_close(io) io.close io.read end begin client, server = Socket.pair(:UNIX, :STREAM) read_after_close(client) rescue => error $stderr.puts error.full_message end begin authority = Localhost::Authority.fetch client, server = Socket.pair(:UNIX, :STREAM) ssl_server = OpenSSL::SSL::SSLSocket.new(server, authority.server_context) ssl_server.sync_close = true ssl_client = OpenSSL::SSL::SSLSocket.new(client, authority.client_context) ssl_client.sync_close = true # If this is not set, `io.read` above will hang which is also a bit odd. Thread.new{ssl_server.accept} ssl_client.connect read_after_close(ssl_client) rescue => error $stderr.puts error.full_message end socketry-io-stream-81522e2/examples/openssl/000077500000000000000000000000001517460720600210365ustar00rootroot00000000000000socketry-io-stream-81522e2/examples/openssl/bad_length.rb000077500000000000000000000023721517460720600234610ustar00rootroot00000000000000#!/usr/bin/env ruby # frozen_string_literal: true # Released under the MIT License. # Copyright, 2024-2025, by Samuel Williams. require "async" require "openssl" require "io/endpoint/ssl_endpoint" require "io/stream" require "localhost" authority = Localhost::Authority.fetch endpoint = IO::Endpoint.tcp("localhost", 12345) server_endpoint = IO::Endpoint::SSLEndpoint.new(endpoint, ssl_context: authority.server_context) client_endpoint = IO::Endpoint::SSLEndpoint.new(endpoint, ssl_context: authority.client_context) message = "0123456789" Async do server_task = Async do $stderr.puts "Server listening on #{server_endpoint}" server_endpoint.accept do |peer| $stderr.puts "Accepted connection from: #{peer.remote_address.inspect}" stream = IO::Stream(peer) 1000.times do 1000.times do stream.write(message) end stream.flush end ensure peer.close end end 100.times do $stderr.puts "Client connecting to #{client_endpoint}" peer = client_endpoint.connect $stderr.puts "Connected to: #{peer.remote_address.inspect}" while data = peer.readpartial(1000*1000) puts "Received: #{data.bytesize} bytes" sleep 0.001 end rescue EOFError # Ignore. ensure peer.close end ensure server_task.stop end socketry-io-stream-81522e2/examples/openssl/gems.locked000066400000000000000000000011441517460720600231540ustar00rootroot00000000000000PATH remote: ../.. specs: io-stream (0.4.1) GEM remote: https://rubygems.org/ specs: async (2.17.0) console (~> 1.26) fiber-annotation io-event (~> 1.6, >= 1.6.5) console (1.27.0) fiber-annotation fiber-local (~> 1.1) json fiber-annotation (0.2.0) fiber-local (1.1.0) fiber-storage fiber-storage (1.0.0) io-endpoint (0.13.1) io-event (1.6.5) json (2.7.2) localhost (1.3.1) openssl (3.2.0) PLATFORMS ruby x86_64-linux DEPENDENCIES async io-endpoint io-stream! localhost openssl BUNDLED WITH 2.5.16 socketry-io-stream-81522e2/examples/openssl/gems.rb000066400000000000000000000003521517460720600223160ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024-2025, by Samuel Williams. source "https://rubygems.org" gem "openssl" gem "async" gem "io-endpoint" gem "io-stream", path: "../../" gem "localhost" socketry-io-stream-81522e2/gems.rb000066400000000000000000000010321517460720600170110ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2025, by Samuel Williams. source "https://rubygems.org" gemspec group :maintenance, optional: true do gem "bake-modernize" gem "bake-gem" gem "bake-releases" gem "agent-context" gem "utopia-project" end group :test do gem "sus" gem "covered" gem "decode" gem "rubocop" gem "rubocop-md" gem "rubocop-socketry" gem "bake-test" gem "bake-test-external" gem "sus-fixtures-async" gem "sus-fixtures-openssl" gem "async-safe" end socketry-io-stream-81522e2/guides/000077500000000000000000000000001517460720600170155ustar00rootroot00000000000000socketry-io-stream-81522e2/guides/getting-started/000077500000000000000000000000001517460720600221225ustar00rootroot00000000000000socketry-io-stream-81522e2/guides/getting-started/readme.md000066400000000000000000000065221517460720600237060ustar00rootroot00000000000000# Getting Started This guide explains how to use `io-stream` to add efficient buffering to Ruby IO objects. ## Overview `io-stream` provides a buffered stream wrapper for any IO-like object in Ruby. It wraps standard Ruby IO instances (files, sockets, pipes) and adds buffering for both reading and writing operations, significantly improving performance for applications that perform many small reads or writes. ## Installation Add the gem to your project: ~~~ bash $ bundle add io-stream ~~~ ## Core Concepts ### Buffered Streams `io-stream` provides buffering through the {IO::Stream::Buffered} class, which wraps any IO object. Buffering reduces the number of system calls by accumulating data in memory before actually reading from or writing to the underlying IO. ### Read and Write Buffers The stream maintains separate buffers for reading and writing: - **Read buffer**: Accumulates data from the underlying IO, allowing multiple small reads without system calls - **Write buffer**: Accumulates data to write, flushing to the underlying IO only when the buffer is full or explicitly flushed ## Usage ### Wrapping an IO Object You can wrap any IO-like object using {IO::Stream}: ~~~ ruby require 'io/stream' # Wrap a file file = File.open("data.txt", "w+") stream = IO::Stream(file) # Wrap a socket require 'socket' socket = TCPSocket.new("example.com", 80) stream = IO::Stream(socket) ~~~ ### Opening Files Directly You can also open files directly as buffered streams: ~~~ ruby require 'io/stream' # Open a file for reading stream = IO::Stream::Buffered.open("data.txt", "r") data = stream.read stream.close # Open with a block (auto-closes) IO::Stream::Buffered.open("data.txt", "w") do |stream| stream.write("Hello, World!") stream.flush end ~~~ ### Reading Data The {IO::Stream::Readable} module provides various methods for reading: ~~~ ruby require 'io/stream' IO::Stream::Buffered.open("data.txt", "r") do |stream| # Read entire stream content = stream.read # Read specific number of bytes chunk = stream.read(1024) # Read a line line = stream.gets # Read all lines lines = stream.readlines # Check for end of stream if stream.eof? puts "Reached end of file" end end ~~~ ### Writing Data The {IO::Stream::Writable} module provides methods for writing: ~~~ ruby require 'io/stream' IO::Stream::Buffered.open("output.txt", "w") do |stream| # Write data (buffered) stream.write("Hello, ") stream.write("World!") # Write with automatic newline stream.puts("This is a line") # Flush buffer to ensure data is written stream.flush end ~~~ ## Important Behaviors ### Automatic Flushing The write buffer automatically flushes when: - The buffer size reaches the minimum write size (default: 64KB). - You call {IO::Stream::Writable#puts} (always flushes immediately). - You call {IO::Stream::Writable#flush} explicitly. - The stream is closed. ### Manual Flushing For applications that need precise control over when data is written: ~~~ ruby stream.write("Important data") stream.flush # Ensure data is written immediately ~~~ ### Buffer Sizes You can customize buffer sizes when creating streams: ~~~ ruby # Smaller buffer for interactive applications stream = IO::Stream::Buffered.new(io, minimum_write_size: 4096) # Larger buffer for bulk operations stream = IO::Stream::Buffered.new(io, minimum_write_size: 256 * 1024) ~~~ socketry-io-stream-81522e2/guides/high-performance-io/000077500000000000000000000000001517460720600226405ustar00rootroot00000000000000socketry-io-stream-81522e2/guides/high-performance-io/readme.md000066400000000000000000000142551517460720600244260ustar00rootroot00000000000000# High Performance IO This guide explains how to achieve optimal performance when using `io-stream` by understanding and controlling flush behavior. ## Overview The key to high-performance IO with `io-stream` is understanding when and how to flush your write buffer. Improper flush timing can significantly impact throughput, latency, and CPU usage. This guide helps you choose the right buffering strategy for your application. ## Why Buffering Matters Every write to an underlying IO object (file, socket, pipe) involves a system call, which has overhead: - **Context switching**: Transferring control between userspace and kernel space. - **System call overhead**: The cost of invoking kernel functions. - **Network packet overhead**: For sockets, each small write may trigger a separate packet. Buffering solves this by accumulating data in memory and performing larger, less frequent writes. However, buffering introduces latency - data sits in memory until flushed. Use buffering when you need: - **High throughput**: Maximize data transfer rate for bulk operations. - **Reduced CPU usage**: Minimize system call overhead when writing many small pieces. - **Efficient network utilization**: Avoid sending many tiny packets. ## The Flush/Throughput Tradeoff There's a fundamental tradeoff between responsiveness and throughput: ```mermaid graph LR A[Immediate Flush] -->|Low Latency| B[Responsive] A -->|Many System Calls| C[Lower Throughput] D[Delayed Flush] -->|Higher Latency| E[Buffered] D -->|Fewer System Calls| F[Higher Throughput] ``` **Immediate flushing** (after every write): - ✅ Data is sent immediately - low latency. - ✅ Simple mental model - predictable behavior. - ❌ High system call overhead. - ❌ Lower maximum throughput. - ❌ More CPU usage. - ❌ Network inefficiency (many small packets). **Buffered flushing** (accumulate before sending): - ✅ Fewer system calls - higher throughput. - ✅ Better CPU efficiency. - ✅ More efficient network packet utilization. - ❌ Data is delayed - higher latency. - ❌ Requires careful flush management. ## Automatic Flush Behavior `io-stream` automatically flushes in these situations: ~~~ ruby # 1. Buffer reaches minimum_write_size (default: 64KB) stream.write("x" * 65536) # Automatically flushes # 2. Using puts() always flushes stream.puts("This is flushed immediately") # 3. Closing the stream stream.close # Flushes any remaining data ~~~ ## Choosing Your Flush Strategy ### Strategy 1: Let Automatic Flushing Handle It Best for: Bulk data transfer, file processing, log writing. ~~~ ruby require 'io/stream' # Default behavior - automatic flush at 64KB stream = IO::Stream::Buffered.open("large_file.dat", "w") # Write lots of data 1000.times do |i| stream.write("Record #{i}\n" * 1000) end stream.close # Final flush on close ~~~ **When to use:** - Writing large amounts of data continuously. - Throughput is more important than latency. - You don't need interactive feedback. ### Strategy 2: Manual Flush at Logical Boundaries Best for: Request/response protocols, transaction processing, structured logging. ~~~ ruby require 'io/stream' require 'socket' socket = TCPSocket.new("example.com", 80) stream = IO::Stream(socket) # Build complete HTTP request stream.write("GET / HTTP/1.1\r\n") stream.write("Host: example.com\r\n") stream.write("Connection: close\r\n") stream.write("\r\n") # Flush after complete request stream.flush # Send request as one operation ~~~ **When to use:** - Message-based protocols (HTTP, Redis, etc.) - You need to send complete "units" of data - Each logical operation should complete atomically - Balance between throughput and responsiveness ### Strategy 3: Immediate Flush for Interactive Applications Best for: Chat applications, streaming responses, real-time dashboards. ~~~ ruby require 'io/stream' # Use smaller buffer for more frequent automatic flushes stream = IO::Stream::Buffered.new( socket, minimum_write_size: 512 # Smaller buffer = more responsive ) # Or flush after every message stream.write(message) stream.flush # Ensure immediate delivery ~~~ **When to use:** - Real-time user interaction required. - Low latency is critical. - Data arrives in small, discrete chunks. ### Strategy 4: Time-Based Flushing Best for: Streaming data, progress updates, monitoring ~~~ ruby require 'io/stream' stream = IO::Stream::Buffered.open("stream.log", "w") last_flush = Time.now loop do stream.write(generate_log_entry) # Flush every second or when buffer is large if Time.now - last_flush > 1.0 stream.flush last_flush = Time.now end end ~~~ **When to use:** - Ensuring regular progress visibility. - Protecting against data loss (periodic flush to disk). - Streaming applications with real-time monitoring. ### Strategy 5: Readiness based flushing Best for: interactive protocols, terminal applications, chat servers. ~~~ ruby require 'io/stream' stream = IO::Stream::Buffered.new(socket, minimum_write_size: 1024) loop do # Blocking read from a queue of messages to send: chunk = queue.pop stream.write(chunk) if queue.empty? # Flush when we are likely to block on the queue: stream.flush end end ~~~ **When to use:** - When you have unpredictable message arrival patterns. - When you want to ensure the lowest possible latency while still benefiting from buffering when messages arrive in bursts. ## Buffer Size Configuration The `minimum_write_size` parameter controls when automatic flushing occurs: ~~~ ruby # Very small buffer - more responsive, lower throughput stream = IO::Stream::Buffered.new(io, minimum_write_size: 1024) # Default - balanced (64KB) stream = IO::Stream::Buffered.new(io) # Large buffer - maximum throughput, higher latency stream = IO::Stream::Buffered.new(io, minimum_write_size: 512 * 1024) ~~~ ### Choosing Buffer Size **Small buffers (1-8KB):** - Interactive protocols (terminal, chat). - Real-time data visualization. - Acceptable: Lower throughput. **Medium buffers (8-64KB):** - Web servers (default is good). - Application servers. - Database connections. - Balance of throughput and responsiveness. **Large buffers (64KB-1MB):** - File processing. - Bulk data transfer. - Video encoding. - Logging systems. - Only latency-insensitive applications. socketry-io-stream-81522e2/guides/links.yaml000066400000000000000000000000771517460720600210250ustar00rootroot00000000000000getting-started: order: 1 high-performance-io: order: 2 socketry-io-stream-81522e2/io-stream.gemspec000066400000000000000000000013551517460720600210060ustar00rootroot00000000000000# frozen_string_literal: true require_relative "lib/io/stream/version" Gem::Specification.new do |spec| spec.name = "io-stream" spec.version = IO::Stream::VERSION spec.summary = "Provides a generic stream wrapper for IO instances." spec.authors = ["Samuel Williams"] spec.license = "MIT" spec.cert_chain = ["release.cert"] spec.signing_key = File.expand_path("~/.gem/release.pem") spec.homepage = "https://github.com/socketry/io-stream" spec.metadata = { "documentation_uri" => "https://socketry.github.io/io-stream/", "source_code_uri" => "https://github.com/socketry/io-stream.git", } spec.files = Dir.glob(["{context,lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) spec.required_ruby_version = ">= 3.3" end socketry-io-stream-81522e2/lib/000077500000000000000000000000001517460720600163035ustar00rootroot00000000000000socketry-io-stream-81522e2/lib/io/000077500000000000000000000000001517460720600167125ustar00rootroot00000000000000socketry-io-stream-81522e2/lib/io/stream.rb000066400000000000000000000007721517460720600205400ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2026, by Samuel Williams. require_relative "stream/version" require_relative "stream/buffered" require_relative "stream/duplex" # @namespace class IO # Convert any IO-like object into a buffered stream. # @parameter io [IO] The IO object to wrap. # @returns [IO::Stream::Buffered] A buffered stream wrapper. def self.Stream(io) if io.is_a?(Stream::Buffered) io else Stream::Buffered.wrap(io) end end end socketry-io-stream-81522e2/lib/io/stream/000077500000000000000000000000001517460720600202055ustar00rootroot00000000000000socketry-io-stream-81522e2/lib/io/stream/buffered.rb000066400000000000000000000065761517460720600223320ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024-2026, by Samuel Williams. require_relative "generic" require_relative "connection_reset_error" module IO::Stream # A buffered stream implementation that wraps an underlying IO object to provide efficient buffered reading and writing. class Buffered < Generic # Open a file and wrap it in a buffered stream. # @parameter path [String] The file path to open. # @parameter mode [String] The file mode (e.g., "r+", "w", "a"). # @parameter options [Hash] Additional options passed to the stream constructor. # @returns [IO::Stream::Buffered] A buffered stream wrapping the opened file. def self.open(path, mode = "r+", **options) stream = self.new(::File.open(path, mode), **options) return stream unless block_given? begin yield stream ensure stream.close end end # Wrap an existing IO object in a buffered stream. # @parameter io [IO] The IO object to wrap. # @parameter options [Hash] Additional options passed to the stream constructor. # @returns [IO::Stream::Buffered] A buffered stream wrapping the IO object. def self.wrap(io, **options) if io.respond_to?(:buffered=) io.buffered = false elsif io.respond_to?(:sync=) io.sync = true end stream = self.new(io, **options) return stream unless block_given? begin yield stream ensure stream.close end end # Initialize a new buffered stream. # @parameter io [IO] The underlying IO object to wrap. def initialize(io, ...) super(...) @io = io if io.respond_to?(:timeout) @timeout = io.timeout else @timeout = nil end end # @attribute [IO] The wrapped IO object. attr :io # Get the underlying IO object. # @returns [IO] The underlying IO object. def to_io @io.to_io end # Check if the stream is closed. # @returns [Boolean] True if the stream is closed. def closed? @io.closed? end # Close the read end of the stream. def close_read @io.close_read end # Close the write end of the stream. def close_write super ensure @io.close_write end # Check if the stream is readable. # @returns [Boolean] True if the stream is readable. def readable? super && @io.readable? end protected if RUBY_VERSION < "3.3.6" def sysclose # https://bugs.ruby-lang.org/issues/20723 Thread.new{@io.close}.join end else def sysclose @io.close end end def syswrite(buffer) return @io.write(buffer) end # Reads data from the underlying stream as efficiently as possible. def sysread(size, buffer) # Come on Ruby, why couldn't this just return `nil`? EOF is not exceptional. Every file has one. while true result = @io.read_nonblock(size, buffer, exception: false) case result when :wait_readable @io.wait_readable(@io.timeout) or raise ::IO::TimeoutError, "read timeout" when :wait_writable @io.wait_writable(@io.timeout) or raise ::IO::TimeoutError, "write timeout" else return result end end rescue OpenSSL::SSL::SSLError => error if error.message =~ /unexpected eof while reading/ raise ConnectionResetError, "Connection reset by peer!" end rescue Errno::ECONNRESET raise ConnectionResetError, "Connection reset by peer!" rescue Errno::EBADF raise ::IOError, "stream closed" end end end socketry-io-stream-81522e2/lib/io/stream/connection_reset_error.rb000066400000000000000000000004721517460720600253070ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025-2026, by Samuel Williams. module IO::Stream # Represents a connection reset error in IO streams, usually occurring when the remote side closes the connection unexpectedly. class ConnectionResetError < Errno::ECONNRESET end end socketry-io-stream-81522e2/lib/io/stream/duplex.rb000066400000000000000000000073631517460720600220440ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2026, by Samuel Williams. module IO::Stream # A low-level duplex IO adapter that composes distinct readable and writable endpoints. class Duplex # Initialize a duplex transport from separate readable and writable endpoints. # @parameter input [IO] The readable endpoint. # @parameter output [IO] The writable endpoint. def initialize(input, output = input) @input = input @output = output end attr :input attr :output # Return the underlying IO used to represent this duplex stream. # @returns [IO] The readable endpoint if available, otherwise the writable endpoint. def to_io @input || @output end # Return the maximum timeout across both endpoints. # @returns [Numeric | Nil] The effective timeout, or `nil` if no timeout is configured. def timeout [@input.timeout, @output.timeout].compact.max end # Update the timeout on both endpoints. # @parameter duration [Numeric | Nil] The timeout to assign. def timeout=(duration) @input.timeout = duration @output.timeout = duration end # Check whether both endpoints are closed. # @returns [Boolean] True if the duplex stream can no longer read or write. def closed? @input.closed? && @output.closed? end # Close the readable endpoint. def close_read return if @input.closed? if @input.respond_to?(:close_read) @input.close_read else @input.close end end # Close the writable endpoint. def close_write return if @output.closed? if @output.respond_to?(:close_write) @output.close_write else @output.close end end # Check whether the readable endpoint may still produce data. # @returns [Boolean] True if the readable endpoint reports it is readable. def readable? @input.readable? end # Close both endpoints. def close @output.close unless @output.closed? @input.close unless @input.closed? end # Write data to the writable endpoint. # @parameter buffer [String] The data to write. # @returns [Integer] The number of bytes written. def write(buffer) @output.write(buffer) end # Read data from the readable endpoint without blocking. # @parameter size [Integer] The maximum number of bytes to read. # @parameter buffer [String] The destination buffer. # @parameter exception [Boolean] Whether to raise on `:wait_readable` and EOF conditions. # @returns [String | Symbol | Nil] Data read from the endpoint, or the underlying non-blocking result. def read_nonblock(size, buffer, exception: false) @input.read_nonblock(size, buffer, exception: exception) end # Wait until the readable endpoint can be read. # @parameter duration [Numeric | Nil] The maximum time to wait. # @returns [Boolean] True if the endpoint became readable. def wait_readable(duration = @timeout) @input.wait_readable(duration) end # Wait until the writable endpoint can be written. # @parameter duration [Numeric | Nil] The maximum time to wait. # @returns [Boolean] True if the endpoint became writable. def wait_writable(duration = @timeout) @output.wait_writable(duration) end end # Construct a buffered stream from either one duplex IO-like object or two separate endpoints. # @parameter input [IO] The duplex IO object, or the readable endpoint. # @parameter output [IO | Nil] The writable endpoint, when distinct from the readable endpoint. # @parameter options [Hash] Additional options passed to the buffered stream wrapper. # @returns [IO::Stream::Buffered] A buffered stream wrapping the supplied transport. def self.Duplex(input, output = nil, **options) if output Buffered.wrap(Duplex.new(input, output), **options) else ::IO.Stream(input) end end end socketry-io-stream-81522e2/lib/io/stream/generic.rb000066400000000000000000000040051517460720600221450ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2026, by Samuel Williams. require_relative "string_buffer" require_relative "readable" require_relative "writable" require_relative "shim/buffered" require_relative "shim/readable" require_relative "shim/timeout" require_relative "openssl" module IO::Stream # Base class for stream implementations providing common functionality. class Generic include Readable include Writable # Check if a method is async-safe. # # @parameter method [Symbol] The method name to check. # @returns [Symbol | Boolean] The concurrency guard for the given method. def self.async_safe?(method) Readable.async_safe?(method) || Writable.async_safe?(method) end # Initialize a new generic stream. # @parameter options [Hash] Options passed to included modules. def initialize(**options) super(**options) end # Check if the stream is closed. # @returns [Boolean] False by default, should be overridden by subclasses. def closed? false end # Best effort to flush any unwritten data, and then close the underling IO. def close return if closed? begin self.flush rescue # We really can't do anything here unless we want #close to raise exceptions. ensure self.sysclose end end protected # Closes the underlying IO stream. # This method should be implemented by subclasses to handle the specific closing logic. def sysclose raise NotImplementedError end # Writes data to the underlying stream. # This method should be implemented by subclasses to handle the specific writing logic. # @parameter buffer [String] The data to write. # @returns [Integer] The number of bytes written. def syswrite(buffer) raise NotImplementedError end # Reads data from the underlying stream as efficiently as possible. # This method should be implemented by subclasses to handle the specific reading logic. def sysread(size, buffer) raise NotImplementedError end end end socketry-io-stream-81522e2/lib/io/stream/openssl.rb000066400000000000000000000013021517460720600222110ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024-2026, by Samuel Williams. require "openssl" # @namespace module OpenSSL # @namespace module SSL # SSL socket extensions for stream compatibility. class SSLSocket unless method_defined?(:buffered?) # Check if the SSL socket is buffered. # @returns [Boolean] True if the SSL socket is buffered. def buffered? return to_io.buffered? end end unless method_defined?(:buffered=) # Set the buffered state of the SSL socket. # @parameter value [Boolean] True to enable buffering, false to disable. def buffered=(value) to_io.buffered = value end end end end end socketry-io-stream-81522e2/lib/io/stream/readable.rb000066400000000000000000000351731517460720600223020ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025-2026, by Samuel Williams. require_relative "string_buffer" module IO::Stream # The default block size for IO buffers. Defaults to 256KB (optimized for modern SSDs and networks). BLOCK_SIZE = ENV.fetch("IO_STREAM_BLOCK_SIZE", 1024*256).to_i # The minimum read size for efficient I/O operations. Defaults to the same as BLOCK_SIZE. MINIMUM_READ_SIZE = ENV.fetch("IO_STREAM_MINIMUM_READ_SIZE", BLOCK_SIZE).to_i # The maximum read size for a single read operation. This limit exists because: # 1. System calls like read() cannot handle requests larger than SSIZE_MAX # 2. Very large reads can cause memory pressure and poor interactive performance # 3. Most socket buffers and pipe capacities are much smaller anyway # On 64-bit systems SSIZE_MAX is ~8.8 million MB, on 32-bit it's ~2GB. # Our default of 16MB provides a good balance of throughput and responsiveness, and is page aligned. # It is also a multiple of the minimum read size, so that we can read in chunks without exceeding the maximum. MAXIMUM_READ_SIZE = ENV.fetch("IO_STREAM_MAXIMUM_READ_SIZE", MINIMUM_READ_SIZE * 64).to_i # A module providing readable stream functionality. # # You must implement the `sysread` method to read data from the underlying IO. module Readable ASYNC_SAFE = { read: :readable, read_partial: :readable, read_exactly: :readable, read_until: :readable, peek: :readable, gets: :readable, getc: :readable, getbyte: :readable, readline: :readable, readlines: :readable, readable?: :readable, fill_read_buffer: :readable, eof?: :readable, finished?: :readable, }.freeze # Check if a method is async-safe. # # @parameter method [Symbol] The method name to check. # @returns [Symbol | Boolean] The concurrency guard for the given method. def self.async_safe?(method) ASYNC_SAFE.fetch(method, false) end # Initialize readable stream functionality. # @parameter minimum_read_size [Integer] The minimum size for read operations. # @parameter maximum_read_size [Integer] The maximum size for read operations. # @parameter block_size [Integer] Legacy parameter, use minimum_read_size instead. def initialize(minimum_read_size: MINIMUM_READ_SIZE, maximum_read_size: MAXIMUM_READ_SIZE, block_size: nil, **, &block) @finished = false @read_buffer = StringBuffer.new # Used as destination buffer for underlying reads. @input_buffer = StringBuffer.new # Support legacy block_size parameter for backwards compatibility @minimum_read_size = block_size || minimum_read_size @maximum_read_size = maximum_read_size super(**, &block) if defined?(super) end attr_accessor :minimum_read_size # Legacy accessor for backwards compatibility # @returns [Integer] The minimum read size. def block_size @minimum_read_size end # Legacy setter for backwards compatibility # @parameter value [Integer] The minimum read size. def block_size=(value) @minimum_read_size = value end # Read data from the stream. # @parameter size [Integer | Nil] The number of bytes to read. If nil, read until end of stream. # @parameter buffer [String | Nil] An optional buffer to fill with data instead of allocating a new string. # @returns [String] The data read from the stream, or the provided buffer filled with data. def read(size = nil, buffer = nil) if size == 0 if buffer buffer.clear buffer.force_encoding(Encoding::BINARY) return buffer else return String.new(encoding: Encoding::BINARY) end end if size until @finished or @read_buffer.bytesize >= size # Compute the amount of data we need to read from the underlying stream: read_size = size - @read_buffer.bytesize # Don't read less than @minimum_read_size to avoid lots of small reads: fill_read_buffer(read_size > @minimum_read_size ? read_size : @minimum_read_size) end else until @finished fill_read_buffer end if buffer buffer.replace(@read_buffer) @read_buffer.clear else buffer = @read_buffer @read_buffer = StringBuffer.new end # Read without size always returns a non-nil value, even if it is an empty string. return buffer end return consume_read_buffer(size, buffer) end # Read at most `size` bytes from the stream. Will avoid reading from the underlying stream if possible. # @parameter size [Integer | Nil] The number of bytes to read. If nil, read all available data. # @parameter buffer [String | Nil] An optional buffer to fill with data instead of allocating a new string. # @returns [String] The data read from the stream, or the provided buffer filled with data. def read_partial(size = nil, buffer = nil) if size == 0 if buffer buffer.clear buffer.force_encoding(Encoding::BINARY) return buffer else return String.new(encoding: Encoding::BINARY) end end if !@finished and @read_buffer.empty? fill_read_buffer end return consume_read_buffer(size, buffer) end # Read exactly the specified number of bytes. # @parameter size [Integer] The number of bytes to read. # @parameter exception [Class] The exception to raise if not enough data is available. # @returns [String] The data read from the stream. def read_exactly(size, buffer = nil, exception: EOFError) if buffer = read(size, buffer) if buffer.bytesize != size raise exception, "Could not read enough data!" end return buffer end raise exception, "Stream finished before reading enough data!" end # This is a compatibility shim for existing code that uses `readpartial`. # @parameter size [Integer | Nil] The number of bytes to read. # @parameter buffer [String | Nil] An optional buffer to fill with data instead of allocating a new string. # @returns [String] The data read from the stream. def readpartial(size = nil, buffer = nil) read_partial(size, buffer) or raise EOFError, "Stream finished before reading enough data!" end # Find the index of a pattern in the read buffer, reading more data if needed. # @parameter pattern [String] The pattern to search for. # @parameter offset [Integer] The offset to start searching from. # @parameter limit [Integer | Nil] The maximum number of bytes to read while searching. # @returns [Integer | Nil] The index of the pattern, or nil if not found. private def index_of(pattern, offset, limit, discard = false) # We don't want to split on the pattern, so we subtract the size of the pattern. split_offset = pattern.bytesize - 1 until index = @read_buffer.index(pattern, offset) offset = @read_buffer.bytesize - split_offset offset = 0 if offset < 0 if limit and offset >= limit return nil end unless fill_read_buffer return nil end if discard # If we are discarding, we should consume the read buffer up to the offset: consume_read_buffer(offset) offset = 0 end end return index end # Efficiently read data from the stream until encountering pattern. # @parameter pattern [String] The pattern to match. # @parameter offset [Integer] The offset to start searching from. # @parameter limit [Integer] The maximum number of bytes to read, including the pattern (even if chomped). # @parameter chomp [Boolean] Whether to remove the pattern from the returned data. # @returns [String | Nil] The contents of the stream up until the pattern, or nil if the pattern was not found. def read_until(pattern, offset = 0, limit: nil, chomp: true) if index = index_of(pattern, offset, limit) return nil if limit and index >= limit @read_buffer.freeze matched = @read_buffer.byteslice(0, index+(chomp ? 0 : pattern.bytesize)) @read_buffer = @read_buffer.byteslice(index+pattern.bytesize, @read_buffer.bytesize) return matched end end # Efficiently discard data from the stream until encountering pattern. # @parameter pattern [String] The pattern to match. # @parameter offset [Integer] The offset to start searching from. # @parameter limit [Integer] The maximum number of bytes to read, including the pattern. # @returns [String | Nil] The contents of the stream up until the pattern, or nil if the pattern was not found. def discard_until(pattern, offset = 0, limit: nil) if index = index_of(pattern, offset, limit, true) @read_buffer.freeze if limit and index >= limit @read_buffer = @read_buffer.byteslice(limit, @read_buffer.bytesize) return nil end matched = @read_buffer.byteslice(0, index+pattern.bytesize) @read_buffer = @read_buffer.byteslice(index+pattern.bytesize, @read_buffer.bytesize) return matched end end # Peek at data in the buffer without consuming it. # @parameter size [Integer | Nil] The number of bytes to peek at. If nil, peek at all available data. # @returns [String] The data in the buffer without consuming it. def peek(size = nil) if size until @finished or @read_buffer.bytesize >= size # Compute the amount of data we need to read from the underlying stream: read_size = size - @read_buffer.bytesize # Don't read less than @minimum_read_size to avoid lots of small reads: fill_read_buffer(read_size > @minimum_read_size ? read_size : @minimum_read_size) end return @read_buffer[..([size, @read_buffer.size].min - 1)] end until (block_given? && yield(@read_buffer)) or @finished fill_read_buffer end return @read_buffer end # Read a line from the stream, similar to IO#gets. # @parameter separator [String] The line separator to search for. # @parameter limit [Integer | Nil] The maximum number of bytes to read. # @parameter chomp [Boolean] Whether to remove the separator from the returned line. # @returns [String | Nil] The line read from the stream, or nil if at end of stream. def gets(separator = $/, limit = nil, chomp: false) # Compatibility with IO#gets: if separator.is_a?(Integer) limit = separator separator = $/ end # We don't want to split in the middle of the separator, so we subtract the size of the separator from the start of the search: split_offset = separator.bytesize - 1 offset = 0 until index = @read_buffer.index(separator, offset) offset = @read_buffer.bytesize - split_offset offset = 0 if offset < 0 # If a limit was given, and the offset is beyond the limit, we should return up to the limit: if limit and offset >= limit # As we didn't find the separator, there is nothing to chomp either. return consume_read_buffer(limit) end # If we can't read any more data, we should return what we have: return consume_read_buffer unless fill_read_buffer end # If the index of the separator was beyond the limit: if limit and index >= limit # Return up to the limit: return consume_read_buffer(limit) end # Freeze the read buffer, as this enables us to use byteslice without generating a hidden copy: @read_buffer.freeze line = @read_buffer.byteslice(0, index+(chomp ? 0 : separator.bytesize)) @read_buffer = @read_buffer.byteslice(index+separator.bytesize, @read_buffer.bytesize) return line end # Determins if the stream has consumed all available data. May block if the stream is not readable. # See {readable?} for a non-blocking alternative. # # @returns [Boolean] If the stream is at file which means there is no more data to be read. def finished? if !@read_buffer.empty? return false elsif @finished return true else return !self.fill_read_buffer end end alias eof? finished? # Mark the stream as finished and raise `EOFError`. def finish! @read_buffer.clear @finished = true raise EOFError end alias eof! finish! # Whether there is a chance that a read operation will succeed or not. # @returns [Boolean] If the stream is readable, i.e. a `read` operation has a chance of success. def readable? # If we are at the end of the file, we can't read any more data: if @finished return false end # If the read buffer is not empty, we can read more data: if !@read_buffer.empty? return true end # If the underlying stream is readable, we can read more data: return !closed? end # Close the read end of the stream. def close_read end private # Fills the buffer from the underlying stream. def fill_read_buffer(size = @minimum_read_size) # Limit the read size to avoid exceeding SSIZE_MAX and to manage memory usage. # Very large reads can also hurt interactive performance by blocking for too long. if size > @maximum_read_size size = @maximum_read_size end # This effectively ties the input and output stream together. self.flush if @read_buffer.empty? if sysread(size, @read_buffer) # Console.info(self, name: "read") {@read_buffer.inspect} return true end else if chunk = sysread(size, @input_buffer) @read_buffer << chunk # Console.info(self, name: "read") {@read_buffer.inspect} return true end end # else for both cases above: @finished = true return false end # Consumes at most `size` bytes from the buffer. # @parameter size [Integer | Nil] The amount of data to consume. If nil, consume entire buffer. # @parameter buffer [String | Nil] An optional buffer to fill with data instead of allocating a new string. # @returns [String | Nil] The consumed data, or nil if no data available. def consume_read_buffer(size = nil, buffer = nil) # If we are at finished, and the read buffer is empty, we can't consume anything. if @finished && @read_buffer.empty? # Clear the buffer even when returning nil if buffer buffer.clear buffer.force_encoding(Encoding::BINARY) end return nil end result = nil if size.nil? or size >= @read_buffer.bytesize # Consume the entire read buffer: if buffer buffer.clear buffer << @read_buffer result = buffer else result = @read_buffer end @read_buffer = StringBuffer.new else # We know that we are not going to reuse the original buffer. # But byteslice will generate a hidden copy. So let's freeze it first: @read_buffer.freeze if buffer # Use replace instead of clear + << for better performance buffer.replace(@read_buffer.byteslice(0, size)) result = buffer else result = @read_buffer.byteslice(0, size) end @read_buffer = @read_buffer.byteslice(size, @read_buffer.bytesize) end return result end end end socketry-io-stream-81522e2/lib/io/stream/shim/000077500000000000000000000000001517460720600211455ustar00rootroot00000000000000socketry-io-stream-81522e2/lib/io/stream/shim/buffered.rb000066400000000000000000000044101517460720600232530ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2025, by Samuel Williams. unless IO.method_defined?(:buffered?, false) class IO # Check if the IO is buffered. # @returns [Boolean] True if the IO is buffered (not synchronized). def buffered? return !self.sync end # Set the buffered state of the IO. # @parameter value [Boolean] True to enable buffering, false to disable. def buffered=(value) self.sync = !value end end end require "socket" unless BasicSocket.method_defined?(:buffered?, false) # Socket extensions for buffering support. class BasicSocket # Check if this socket uses TCP protocol. # @returns [Boolean] True if the socket is TCP over IPv4 or IPv6. def ip_protocol_tcp? local_address = self.local_address return (local_address.afamily == ::Socket::AF_INET || local_address.afamily == ::Socket::AF_INET6) && local_address.socktype == ::Socket::SOCK_STREAM end # Check if the socket is buffered. # @returns [Boolean] True if the socket is buffered. def buffered? return false unless super if ip_protocol_tcp? return !self.getsockopt(::Socket::IPPROTO_TCP, ::Socket::TCP_NODELAY).bool else return true end end # Set the buffered state of the socket. # @parameter value [Boolean] True to enable buffering, false to disable. def buffered=(value) super if ip_protocol_tcp? # When buffered is set to true, TCP_NODELAY shold be disabled. self.setsockopt(::Socket::IPPROTO_TCP, ::Socket::TCP_NODELAY, value ? 0 : 1) end rescue ::Errno::EINVAL # On Darwin, sometimes occurs when the connection is not yet fully formed. Empirically, TCP_NODELAY is enabled despite this result. rescue ::Errno::EOPNOTSUPP # Some platforms may simply not support the operation. end end end require "stringio" unless StringIO.method_defined?(:buffered?, false) # StringIO extensions for buffering support. class StringIO # Check if the StringIO is buffered. # @returns [Boolean] True if the StringIO is buffered (not synchronized). def buffered? return !self.sync end # Set the buffered state of the StringIO. # @parameter value [Boolean] True to enable buffering, false to disable. def buffered=(value) self.sync = !value end end end socketry-io-stream-81522e2/lib/io/stream/shim/readable.rb000066400000000000000000000030601517460720600232300ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2025, by Samuel Williams. class IO unless method_defined?(:readable?, false) # Check if the IO is readable. # @returns [Boolean] True if the IO is readable (not closed). def readable? # Do not call `eof?` here as it is not concurrency-safe and it can block. !closed? end end end require "socket" class BasicSocket unless method_defined?(:readable?, false) # Check if the socket is readable. # @returns [Boolean] True if the socket is readable. def readable? # If we can wait for the socket to become readable, we know that the socket may still be open. result = self.recv_nonblock(1, ::Socket::MSG_PEEK, exception: false) # No data was available - newer Ruby can return nil instead of empty string: return false if result.nil? # Either there was some data available, or we can wait to see if there is data avaialble. return !result.empty? || result == :wait_readable rescue Errno::ECONNRESET, IOError # This might be thrown by recv_nonblock. return false end end end require "stringio" class StringIO unless method_defined?(:readable?, false) # Check if the StringIO is readable. # @returns [Boolean] True if the StringIO is readable (not at EOF). def readable? !eof? end end end require "openssl" class OpenSSL::SSL::SSLSocket unless method_defined?(:readable?, false) # Check if the SSL socket is readable. # @returns [Boolean] True if the SSL socket is readable. def readable? to_io.readable? end end end socketry-io-stream-81522e2/lib/io/stream/shim/timeout.rb000066400000000000000000000011631517460720600231610ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024-2026, by Samuel Williams. require "stringio" class StringIO unless method_defined?(:timeout) # Return the configured timeout for this in-memory stream. # @returns [Numeric | Nil] The configured timeout, if any. def timeout @timeout end end unless method_defined?(:timeout=) # Store timeout state for compatibility with IO-like timeout interfaces. # @parameter duration [Numeric | Nil] The timeout to assign. # @returns [Numeric | Nil] The assigned timeout. def timeout=(duration) @timeout = duration end end end socketry-io-stream-81522e2/lib/io/stream/string_buffer.rb000066400000000000000000000013351517460720600233730ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2025, by Samuel Williams. module IO::Stream # A specialized string buffer for binary data with automatic encoding handling. class StringBuffer < String BINARY = Encoding::BINARY # Initialize a new string buffer with binary encoding. def initialize super force_encoding(BINARY) end # Append a string to the buffer, converting to binary encoding if necessary. # @parameter string [String] The string to append. # @returns [StringBuffer] Self for method chaining. def << string if string.encoding == BINARY super(string) else super(string.b) end return self end alias concat << end end socketry-io-stream-81522e2/lib/io/stream/version.rb000066400000000000000000000002301517460720600222120ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2025, by Samuel Williams. module IO::Stream VERSION = "0.13.0" end socketry-io-stream-81522e2/lib/io/stream/writable.rb000066400000000000000000000054411517460720600223470ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require_relative "readable" module IO::Stream # The minimum write size before flushing. Defaults to 64KB. MINIMUM_WRITE_SIZE = ENV.fetch("IO_STREAM_MINIMUM_WRITE_SIZE", BLOCK_SIZE).to_i # A module providing writable stream functionality. # # You must implement the `syswrite` method to write data to the underlying IO. module Writable ASYNC_SAFE = { write: true, puts: true, flush: true, }.freeze # Check if a method is async-safe. # # @parameter method [Symbol] The method name to check. # @returns [Symbol | Boolean] The concurrency guard for the given method. def self.async_safe?(method) ASYNC_SAFE.fetch(method, false) end # Initialize writable stream functionality. # @parameter minimum_write_size [Integer] The minimum buffer size before flushing. def initialize(minimum_write_size: MINIMUM_WRITE_SIZE, **, &block) @writing = ::Thread::Mutex.new @write_buffer = StringBuffer.new @minimum_write_size = minimum_write_size super(**, &block) if defined?(super) end attr_accessor :minimum_write_size # Flushes buffered data to the stream. def flush return if @write_buffer.empty? @writing.synchronize do self.drain(@write_buffer) end end # Writes `string` to the buffer. When the buffer is full or #sync is true the # buffer is flushed to the underlying `io`. # @parameter string [String] the string to write to the buffer. # @returns [Integer] the number of bytes appended to the buffer. def write(string, flush: false) @writing.synchronize do @write_buffer << string flush |= (@write_buffer.bytesize >= @minimum_write_size) if flush self.drain(@write_buffer) end end return string.bytesize end # Appends `string` to the buffer and returns self for method chaining. # @parameter string [String] the string to write to the stream. def <<(string) write(string) return self end # Write arguments to the stream followed by a separator and flush immediately. # @parameter arguments [Array] The arguments to write to the stream. # @parameter separator [String] The separator to append after each argument. def puts(*arguments, separator: $/) return if arguments.empty? @writing.synchronize do arguments.each do |argument| @write_buffer << argument << separator end self.drain(@write_buffer) end end # Close the write end of the stream by flushing any remaining data. def close_write flush end private def drain(buffer) begin syswrite(buffer) ensure # If the write operation fails, we still need to clear this buffer, and the data is essentially lost. buffer.clear end end end end socketry-io-stream-81522e2/license.md000066400000000000000000000020731517460720600175030ustar00rootroot00000000000000# MIT License Copyright, 2023-2026, by Samuel Williams. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. socketry-io-stream-81522e2/readme.md000066400000000000000000000125441517460720600173220ustar00rootroot00000000000000# IO::Stream Provide a buffered stream implementation for Ruby, independent of the underlying IO. [![Development Status](https://github.com/socketry/io-stream/workflows/Test/badge.svg)](https://github.com/socketry/io-stream/actions?workflow=Test) ## Motivation I built this gem because working with IO in Ruby can be surprisingly difficult. Ruby provides buffering, but the inconsistencies between different IO types made it impossible to write clean, generic code. `OpenSSL::SSL::SSLSocket` maintains its own buffering implementation that behaves differently from regular IO. Some IO types raise `OpenSSL::SSL::SSLError` on connection reset while others raise `Errno::ECONNRESET`. EOF semantics vary. Close operations can hang (especially with SSL sockets). And if you want to work with non-blocking IO using `read_nonblock` and `write_nonblock`, you're constantly handling `:wait_readable` and `:wait_writable` conditions, managing timeouts, and dealing with edge cases that differ across implementations. By providing a standard interface for buffered IO, `io-stream` allows you to write code that works the same way regardless of the underlying IO type. You can wrap any IO object and get consistent buffering behavior, unified error handling, and proper management of blocking/non-blocking operations. This makes it much easier to write high-performance IO code without worrying about the quirks of each specific IO implementation. Over time, as we've upstreamed more fixes into Ruby, we've been able to reduce the number of workarounds needed, but the core value of `io-stream` remains: a single, predictable interface for all your IO needs. ## Usage Please see the [project documentation](https://socketry.github.io/io-stream/) for more details. - [Getting Started](https://socketry.github.io/io-stream/guides/getting-started/index) - This guide explains how to use `io-stream` to add efficient buffering to Ruby IO objects. - [High Performance IO](https://socketry.github.io/io-stream/guides/high-performance-io/index) - This guide explains how to achieve optimal performance when using `io-stream` by understanding and controlling flush behavior. ## Releases Please see the [project releases](https://socketry.github.io/io-stream/releases/index) for all releases. ### v0.13.0 - `IO::Stream::Duplex(io)` is equivalent to `IO::Stream(io)`. ### v0.12.0 - Introduce `IO::Stream::Duplex` as a low-level duplex transport for composing separate input and output endpoints. - Add `IO::Stream::Duplex(input, output)` as a convenient constructor that returns a buffered stream wrapping a duplex transport. - Add a timeout compatibility shim for `StringIO` so duplex streams composed from in-memory endpoints can participate in the timeout interface consistently. - Remove old OpenSSL method shims. ### v0.11.0 - Introduce `class IO::Stream::ConnectionResetError < Errno::ECONNRESET` to standardize connection reset error handling across different IO types. - `OpenSSL::SSL::SSLSocket` raises `OpenSSL::SSL::SSLError` on connection reset, while other IO types raise `Errno::ECONNRESET`. `SSLError` is now rescued and re-raised as `IO::Stream::ConnectionResetError` for consistency. ### v0.10.0 - Rename `done?` to `finished?` for clarity and consistency. ### v0.9.1 - Fix EOF behavior to match Ruby IO semantics: `read()` returns empty string `""` at EOF while `read(size)` returns `nil` at EOF. ### v0.9.0 - Add support for `buffer` parameter in `read`, `read_exactly`, and `read_partial` methods to allow reading into a provided buffer. ### v0.8.0 - On Ruby v3.3+, use `IO#write` directly instead of `IO#write_nonblock`, for better performance. - Introduce support for `Readable#discard_until` method to discard data until a specific pattern is found. ### v0.7.0 - Split stream functionality into separate `Readable` and `Writable` modules for better modularity and composition. - Remove unused timeout shim functionality. - 100% documentation coverage. ### v0.6.1 - Fix compatibility with Ruby v3.3.0 - v3.3.6 where broken `@io.close` could hang. ### v0.6.0 - Improve compatibility of `gets` implementation to better match Ruby's IO\#gets behavior. ## See Also - [async-io](https://github.com/socketry/async-io) — Where this implementation originally came from. ## Contributing We welcome contributions to this project. 1. Fork it. 2. Create your feature branch (`git checkout -b my-new-feature`). 3. Commit your changes (`git commit -am 'Add some feature'`). 4. Push to the branch (`git push origin my-new-feature`). 5. Create new Pull Request. ### Running Tests To run the test suite: ``` shell bundle exec sus ``` ### Making Releases To make a new release: ``` shell bundle exec bake gem:release:patch # or minor or major ``` ### Developer Certificate of Origin In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed. ### Community Guidelines This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers. socketry-io-stream-81522e2/release.cert000066400000000000000000000033141517460720600200350ustar00rootroot00000000000000-----BEGIN CERTIFICATE----- MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11 ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2 9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/ c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp 8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8 voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg= -----END CERTIFICATE----- socketry-io-stream-81522e2/releases.md000066400000000000000000000071101517460720600176610ustar00rootroot00000000000000# Releases ## v0.13.0 - `IO::Stream::Duplex(io)` is equivalent to `IO::Stream(io)`. ## v0.12.0 - Introduce `IO::Stream::Duplex` as a low-level duplex transport for composing separate input and output endpoints. - Add `IO::Stream::Duplex(input, output)` as a convenient constructor that returns a buffered stream wrapping a duplex transport. - Add a timeout compatibility shim for `StringIO` so duplex streams composed from in-memory endpoints can participate in the timeout interface consistently. - Remove old OpenSSL method shims. ## v0.11.0 - Introduce `class IO::Stream::ConnectionResetError < Errno::ECONNRESET` to standardize connection reset error handling across different IO types. - `OpenSSL::SSL::SSLSocket` raises `OpenSSL::SSL::SSLError` on connection reset, while other IO types raise `Errno::ECONNRESET`. `SSLError` is now rescued and re-raised as `IO::Stream::ConnectionResetError` for consistency. ## v0.10.0 - Rename `done?` to `finished?` for clarity and consistency. ## v0.9.1 - Fix EOF behavior to match Ruby IO semantics: `read()` returns empty string `""` at EOF while `read(size)` returns `nil` at EOF. ## v0.9.0 - Add support for `buffer` parameter in `read`, `read_exactly`, and `read_partial` methods to allow reading into a provided buffer. ## v0.8.0 - On Ruby v3.3+, use `IO#write` directly instead of `IO#write_nonblock`, for better performance. - Introduce support for `Readable#discard_until` method to discard data until a specific pattern is found. ## v0.7.0 - Split stream functionality into separate `Readable` and `Writable` modules for better modularity and composition. - Remove unused timeout shim functionality. - 100% documentation coverage. ## v0.6.1 - Fix compatibility with Ruby v3.3.0 - v3.3.6 where broken `@io.close` could hang. ## v0.6.0 - Improve compatibility of `gets` implementation to better match Ruby's IO\#gets behavior. ## v0.5.0 - Add support for `read_until(limit:)` parameter to limit the amount of data read. - Minor documentation improvements. ## v0.4.3 - Add comprehensive tests for `buffered?` method on `SSLSocket`. - Ensure TLS connections have correct buffering behavior. - Improve test suite organization and readability. ## v0.4.2 - Add external test suite for better integration testing. - Update dependencies and improve code style with RuboCop. ## v0.4.1 - Add compatibility fix for `SSLSocket` raising `EBADF` errors. - Fix `IO#close` hang issue in certain scenarios. - Add `#to_io` method to `IO::Stream::Buffered` for better compatibility. - Modernize gem structure and dependencies. ## v0.4.0 - Add convenient `IO.Stream()` constructor method for creating buffered streams. ## v0.3.0 - Add support for timeouts with compatibility shims for various IO types. ## v0.2.0 - Prefer `write_nonblock` in `syswrite` implementation for better non-blocking behavior. - Add test cases for crash scenarios. ## v0.1.1 - Improve buffering compatibility by falling back to `sync=` when `buffered=` is not available. ## v0.1.0 - Rename `IO::Stream::BufferedStream` to `IO::Stream::Buffered` for consistency. - Add comprehensive tests and improved OpenSSL support with compatibility shims. - Improve compatibility with Darwin/macOS systems. - Fix monkey patches for various IO types. - Add support for `StringIO#buffered?` method. ## v0.0.1 - Initial release with basic buffered stream functionality. - Provide `IO::Stream::Buffered` class for efficient buffered I/O operations. - Add `readable?` method to check stream readability status. - Include basic test suite. socketry-io-stream-81522e2/test/000077500000000000000000000000001517460720600165145ustar00rootroot00000000000000socketry-io-stream-81522e2/test/io/000077500000000000000000000000001517460720600171235ustar00rootroot00000000000000socketry-io-stream-81522e2/test/io/stream.rb000066400000000000000000000017301517460720600207440ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024-2026, by Samuel Williams. require "io/stream" describe IO::Stream do it "can wrap an IO object" do io = StringIO.new stream = IO::Stream(io) expect(stream).to be_a(IO::Stream::Buffered) end it "can wrap an existing stream" do io = StringIO.new stream = IO::Stream(io) stream2 = IO::Stream(stream) expect(stream2).to be_equal(stream) end it "can wrap an existing duplex stream" do input = StringIO.new output = StringIO.new duplex = IO::Stream::Duplex.new(input, output) stream = IO::Stream(duplex) expect(stream).to be_a(IO::Stream::Buffered) expect(stream.io).to be_equal(duplex) end it "provides timeout shims for StringIO-backed duplex streams" do duplex = IO::Stream::Duplex.new(StringIO.new, StringIO.new) expect(duplex.timeout).to be_nil expect(duplex.timeout = 0.5).to be == 0.5 expect(duplex.timeout).to be == 0.5 end end socketry-io-stream-81522e2/test/io/stream/000077500000000000000000000000001517460720600204165ustar00rootroot00000000000000socketry-io-stream-81522e2/test/io/stream/buffered.rb000066400000000000000000000655101517460720600225340ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024-2025, by Samuel Williams. require "io/stream/buffered" require "tempfile" require "sus/fixtures/async/reactor_context" require "sus/fixtures/openssl/verified_certificate_context" require "sus/fixtures/openssl/valid_certificate_context" describe IO::Stream::Buffered do # This constant is part of the public interface, but was renamed to `Async::IO::BLOCK_SIZE`. with "BLOCK_SIZE" do it "should exist and be reasonable" do expect(IO::Stream::BLOCK_SIZE).to be_within(1024...1024*1024) end end with "MAXIMUM_READ_SIZE" do it "should exist and be reasonable" do expect(IO::Stream::MAXIMUM_READ_SIZE).to be_within(1024*64..1024*64*512) end end with ".open" do let(:tempfile) {Tempfile.new("io_stream_buffered_test")} let(:test_file_path) {tempfile.path} after do tempfile.close tempfile.unlink end it "can open a file and return a buffered stream" do stream = IO::Stream::Buffered.open(test_file_path, "w+") expect(stream).to be_a(IO::Stream::Buffered) expect(stream.io).to be_a(File) stream.write("Hello, World!") stream.flush stream.close content = File.read(test_file_path) expect(content).to be == "Hello, World!" end it "can open a file with a block and auto-close" do result = nil stream_reference = nil # Create a separate tempfile for this test to avoid conflicts block_tempfile = Tempfile.new("io_stream_buffered_block_test") block_file_path = block_tempfile.path block_tempfile.close begin result = IO::Stream::Buffered.open(block_file_path, "w+") do |stream| stream_reference = stream expect(stream).to be_a(IO::Stream::Buffered) expect(stream.io).to be_a(File) stream.write("Block test data") stream.flush :block_result end # The block's return value should be returned expect(result).to be == :block_result # The stream should be automatically closed expect(stream_reference).to be(:closed?) # The file should contain the written data content = File.read(block_file_path) expect(content).to be == "Block test data" ensure File.unlink(block_file_path) if File.exist?(block_file_path) end expect(content).to be == "Block test data" end it "ensures stream is closed even when block raises an exception" do stream_reference = nil expect do IO::Stream::Buffered.open(test_file_path, "w+") do |stream| stream_reference = stream stream.write("Exception test") stream.flush raise StandardError, "Test exception" end end.to raise_exception(StandardError, message: be == "Test exception") # The stream should be closed despite the exception expect(stream_reference).to be(:closed?) # The file should still contain the written data content = File.read(test_file_path) expect(content).to be == "Exception test" end end end AUnidirectionalStream = Sus::Shared("a unidirectional stream") do it "should be able to read and write data" do server.write "Hello, World!" server.flush expect(client.read(13)).to be == "Hello, World!" end with "#buffered?" do it "should not be buffered" do expect(client.io).not.to be(:buffered?) end end with "#read" do it "can read zero length" do data = client.read(0) expect(data).to be == "" expect(data.encoding).to be == Encoding::BINARY end it "reads everything" do server.write "Hello World" server.close expect(client).to receive(:sysread).twice expect(client.read).to be == "Hello World" expect(client).to be(:eof?) end it "reads until finished" do server.close # Subsequent reads should return nil: expect(client.read(1)).to be_nil # Reading with no length should return an empty string: expect(client.read).to be == "" end it "reads only the amount requested" do server.write "Hello World" server.close expect(client).to receive(:sysread).once expect(client.read_partial(4)).to be == "Hell" expect(client).not.to be(:eof?) expect(client.read_partial(20)).to be == "o World" end it "times out when reading" do client.io.timeout = 0.001 expect do client.read(1) end.to raise_exception(::IO::TimeoutError) end with "buffer parameter" do it "can read into a provided buffer" do server.write "Hello World!" server.close buffer = String.new("existing content") result = client.read(5, buffer) # Should return the same buffer object expect(result).to be_equal(buffer) # Buffer should contain the read data and have binary encoding expect(result).to be == "Hello" expect(result.encoding).to be == Encoding::BINARY end it "clears existing buffer content before reading" do server.write "Hello World!" server.close buffer = String.new("old data") result = client.read(5, buffer) expect(result).to be_equal(buffer) expect(result).to be == "Hello" end it "works with zero-length reads" do buffer = String.new("content") result = client.read(0, buffer) expect(result).to be_equal(buffer) expect(result).to be == "" expect(result.encoding).to be == Encoding::BINARY end it "works when reading entire stream" do server.write "Hello World!" server.close buffer = String.new result = client.read(nil, buffer) expect(result).to be_equal(buffer) expect(result).to be == "Hello World!" end it "works with partial reads spanning multiple buffer fills" do # Write more data than the typical buffer size to force multiple reads large_data = "A" * 1000 + "B" * 1000 + "C" * 1000 server.write large_data server.close buffer = String.new result = client.read(2500, buffer) expect(result).to be_equal(buffer) expect(result.bytesize).to be == 2500 expect(result).to be == large_data[0, 2500] end it "returns nil when stream is finished and buffer is empty" do server.close buffer = String.new("content") result = client.read(10, buffer) expect(result).to be_nil # Buffer should be cleared even when returning nil: expect(buffer).to be == "" end end end with "#peek" do it "can peek at the read buffer" do server.write "Hello World" server.close expect(client).to receive(:sysread).once expect(client.peek(4)).to be == "Hell" expect(client.peek(4)).to be == "Hell" expect(client.read_partial).to be == "Hello World" end it "peeks everything" do server.write "Hello World" server.close expect(client).to receive(:sysread).twice expect(client.peek).to be == "Hello World" expect(client.read).to be == "Hello World" expect(client).to be(:eof?) end it "peeks only the amount requested" do server.write "Hello World" server.close expect(client).to receive(:sysread).twice expect(client.peek(4)).to be == "Hell" expect(client.read_partial(4)).to be == "Hell" expect(client).not.to be(:eof?) expect(client.peek(20)).to be == "o World" expect(client.read_partial(20)).to be == "o World" expect(client).to be(:eof?) end it "peeks everything when requested bytes is too large" do server.write "Hello World" server.close expect(client).to receive(:sysread).twice expect(client.peek(400)).to be == "Hello World" expect(client.read_partial(400)).to be == "Hello World" expect(client).to be(:eof?) end end with "#read_exactly" do it "can read several bytes" do server.write "Hello World" server.close expect(client.read_exactly(4)).to be == "Hell" end it "can raise exception if io is eof" do server.close expect do client.read_exactly(4) end.to raise_exception(EOFError) end with "buffer parameter" do it "can read exactly into a provided buffer" do server.write "Hello World!" server.close buffer = String.new("existing content") result = client.read_exactly(4, buffer) # Should return the same buffer object expect(result).to be_equal(buffer) # Buffer should contain exactly the requested data expect(buffer).to be == "Hell" # Should have binary encoding expect(buffer.encoding).to be == Encoding::BINARY end it "clears existing buffer content before reading" do server.write "Hello World!" server.close buffer = String.new("old data") client.read_exactly(4, buffer) expect(buffer).to be == "Hell" end it "raises exception when not enough data available" do server.write "Hi" # Only 2 bytes server.close buffer = String.new("content") expect do client.read_exactly(4, buffer) end.to raise_exception(EOFError, message: be =~ /Could not read enough data/) # Buffer should still contain the partial data that was read expect(buffer).to be == "Hi" end it "raises exception when stream is already at EOF" do server.close buffer = String.new("content") expect do client.read_exactly(4, buffer) end.to raise_exception(EOFError, message: be =~ /Stream finished before reading enough data/) # Buffer should be cleared even when exception is raised expect(buffer).to be == "" end it "works with zero-length reads" do buffer = String.new("content") result = client.read_exactly(0, buffer) expect(result).to be_equal(buffer) expect(buffer).to be == "" expect(buffer.encoding).to be == Encoding::BINARY end it "works with custom exception class" do server.write "Hi" # Only 2 bytes server.close buffer = String.new("content") expect do client.read_exactly(4, buffer, exception: StandardError) end.to raise_exception(StandardError, message: be =~ /Could not read enough data/) expect(buffer).to be == "Hi" end it "works when exactly the right amount of data is available" do server.write "Hello" server.close buffer = String.new result = client.read_exactly(5, buffer) expect(result).to be_equal(buffer) expect(buffer).to be == "Hello" expect(buffer.bytesize).to be == 5 end it "works with mix of buffer and non-buffer calls" do server.write "Hello World!" server.close # First call without buffer data1 = client.read_exactly(5) expect(data1).to be == "Hello" # Second call with buffer buffer = String.new("existing") result = client.read_exactly(1, buffer) expect(result).to be_equal(buffer) expect(buffer).to be == " " # Third call without buffer again data3 = client.read_exactly(6) expect(data3).to be == "World!" end it "reads across multiple buffer fills" do # Write more data than typical buffer to force multiple reads large_data = "A" * 1000 + "B" * 1000 + "C" * 1000 server.write large_data server.close buffer = String.new result = client.read_exactly(2500, buffer) expect(result).to be_equal(buffer) expect(buffer.bytesize).to be == 2500 expect(buffer).to be == large_data[0, 2500] end end end with "#read_until" do it "can read a line" do server.write("hello\nworld\n") server.close expect(client.read_until("\n")).to be == "hello" expect(client.read_until("\n")).to be == "world" expect(client.read_until("\n")).to be_nil end it "can read with a limit" do server.write("hello\nworld\n") server.close expect(client.read_until("\n", limit: 4)).to be_nil expect(client.read_until("\n", limit: 5)).to be_nil expect(client.read_until("\n", limit: 6)).to be == "hello" expect(client.read_until("\n", limit: nil)).to be == "world" end with "1-byte block size" do it "can read a line with a multi-byte pattern" do server.write("hello\nworld\n") server.close client.block_size = 1 expect(client.read_until("\n")).to be == "hello" expect(client.read_until("\n")).to be == "world" expect(client.read_until("\n")).to be_nil end end end with "#gets" do it "can read a line" do server.write("hello\nworld\nremainder") server.close expect(client.gets).to be == "hello\n" expect(client.gets).to be == "world\n" expect(client.gets).to be == "remainder" expect(client.gets).to be_nil end it "can read with a limit" do server.write("hello\nworld\nremainder") server.close expect(client.gets(4)).to be == "hell" expect(client.gets(5)).to be == "o\n" expect(client.gets(6)).to be == "world\n" expect(client.gets).to be == "remainder" end end with "#read_partial" do def before super string = "Hello World!" server.write(string * 2) server.close end it "should fill the buffer once" do expect(client).to receive(:sysread).once expect(client.read_partial(12)).to be == "Hello World!" expect(client.read_partial(12)).to be == "Hello World!" end it "with a normal partial_read" do expect(client.read_partial(1).encoding).to be == Encoding::BINARY end it "with a zero-length partial_read" do expect(client.read_partial(0).encoding).to be == Encoding::BINARY end with "buffer parameter" do it "can read_partial into a provided buffer" do buffer = String.new("existing content") result = client.read_partial(5, buffer) # Should return the same buffer object expect(result).to be_equal(buffer) # Buffer should contain the read data and have binary encoding expect(result).to be == "Hello" expect(result.encoding).to be == Encoding::BINARY end it "clears existing buffer content before reading" do buffer = String.new("old data") result = client.read_partial(5, buffer) expect(result).to be_equal(buffer) expect(result).to be == "Hello" end it "works with zero-length partial reads" do buffer = String.new("content") result = client.read_partial(0, buffer) expect(result).to be_equal(buffer) expect(result).to be == "" expect(result.encoding).to be == Encoding::BINARY end it "works when reading all available data" do buffer = String.new result = client.read_partial(nil, buffer) expect(result).to be_equal(buffer) expect(result).to be == "Hello World!Hello World!" end it "works with multiple partial reads using same buffer" do buffer = String.new # First partial read result1 = client.read_partial(6, buffer) expect(result1).to be_equal(buffer) expect(result1).to be == "Hello " # Second partial read reuses the same buffer result2 = client.read_partial(6, buffer) expect(result2).to be_equal(buffer) expect(result2).to be == "World!" end it "returns empty string when maxlen is 0 even at EOF" do # Read all data first client.read_partial(nil) buffer = String.new("content") result = client.read_partial(0, buffer) expect(result).to be_equal(buffer) expect(result).to be == "" expect(result.encoding).to be == Encoding::BINARY end it "works with mix of buffer and non-buffer calls" do # First call without buffer data1 = client.read_partial(6) expect(data1).to be == "Hello " # Second call with buffer buffer = String.new("existing") result = client.read_partial(6, buffer) expect(result).to be_equal(buffer) expect(result).to be == "World!" # Third call without buffer again data3 = client.read_partial(6) expect(data3).to be == "Hello " end end end with "#write" do it "should read one line" do expect(server).to receive(:syswrite) server.puts "Hello World" server.flush expect(client.gets).to be == "Hello World\n" end it "times out when writing" do server.io.timeout = 0.001 expect do while true server.write("Hello World") end end.to raise_exception(::IO::TimeoutError) end end with "#<<" do it "should append string and return self for method chaining" do result = server << "Hello" expect(result).to be_equal(server) server << " " << "World!" server.flush expect(client.read(12)).to be == "Hello World!" end it "should write data without explicit flush" do server.minimum_write_size = 5 expect(server).to receive(:syswrite).once server << "Hello" expect(client.read(5)).to be == "Hello" end it "should buffer data when below minimum write size" do server.minimum_write_size = 10 expect(server).not.to receive(:syswrite) server << "Hi" # Data should still be in buffer, not flushed end end with "#flush" do it "should not call write if write buffer is empty" do expect(server).not.to receive(:syswrite) server.flush end it "should flush underlying data when it exceeds block size" do expect(server).to receive(:syswrite).once server.minimum_write_size = 8 8.times do server.write("!") end end end with "#eof?" do it "should return true when there is no data available" do server.close expect(client.eof?).to be_truthy end it "should return false when there is data available" do server.write "Hello, World!" server.flush expect(client.eof?).to be_falsey end end with "#eof!" do it "should immediately raise EOFError" do expect do client.eof! end.to raise_exception(EOFError) expect(client).to be(:eof?) end end with "#readable?" do it "should return true when the stream might be open" do expect(client.readable?).to be_truthy end it "should return true when there is data available" do server.write "Hello, World!" server.flush expect(client.readable?).to be_truthy end it "should return false when the stream is known to be closed" do expect(client.readable?).to be_truthy server.close client.read expect(client.readable?).to be_falsey end end with "#close_write" do it "can close the write side of the stream" do server.write("Hello World!") # We are finished writing the request: server.close_write expect(client.read).to be == "Hello World!" expect(client.eof?).to be_truthy end end with "#close" do it "should close the stream" do server.close expect(client.read).to be == "" expect(server.closed?).to be_truthy expect(client.closed?).to be_falsey end it "server should be idempotent" do server.close server.close expect(server.closed?).to be_truthy expect(client.closed?).to be_falsey end it "client be idempotent" do client.close client.close expect(client.closed?).to be_truthy expect(server.closed?).to be_falsey end it "should ignore write failures on close" do server.write(".") # Close the underlying IO for whatever reason: server.io.close # This should not raise an exception: server.close expect(server).to be(:closed?) end it "can't read after closing" do client.close expect do client.read end.to raise_exception(::IOError) end it "can't write after closing" do server.close expect do server.write("Hello World") server.flush end.to raise_exception(::IOError) end it "can close while reading from a different thread" do reader = Thread.new do Thread.current.report_on_exception = false client.read end # Wait for the thread to start reading: Thread.pass until reader.backtrace(0, 1).find{|line| line.include?("wait_readable")} client.close expect do reader.join end.to raise_exception(::IOError) end end with "#drain_write_buffer" do include Sus::Fixtures::Async::ReactorContext let(:buffer_size) {1024*6} it "can interleave calls to flush" do writers = 2.times.map do |i| reactor.async do buffer = i.to_s * buffer_size 128.times do server.write(buffer) server.flush end end end reader = reactor.async do while data = client.read(buffer_size) expect(data).to be == (data[0] * buffer_size) end end writers.each(&:wait) server.close reader.wait end it "handles write failures" do client.close task = reactor.async do # We do this as triggering EPIPE may require us to flush the network buffers: 100.times do server.write("Hello World" * 1024) server.flush end rescue Errno::EPIPE => error error rescue Errno::ECONNRESET => error # OpenSSL sockets may raise ECONNRESET instead of EPIPE because they may try to read when writing: error end expect(task.wait).to be_a(Errno::EPIPE).or be_a(Errno::ECONNRESET) write_buffer = server.instance_variable_get(:@write_buffer) expect(write_buffer).to be(:empty?) end end with "#discard_until" do it "can discard data until pattern" do server.write("hello\nworld\ntest") server.close # Discard until "\n" - should return chunk ending with the pattern chunk = client.discard_until("\n") expect(chunk).not.to be_nil expect(chunk).to be(:end_with?, "\n") # Read the remaining data to verify it starts with "world" expect(client.read(5)).to be == "world" # Discard until "t" - should return chunk ending with the pattern chunk = client.discard_until("t") expect(chunk).not.to be_nil expect(chunk).to be(:end_with?, "t") # Read remaining data expect(client.read).to be == "est" end it "returns nil when pattern not found and discards all data" do server.write("hello world") server.close expect(client.discard_until("\n")).to be_nil # Data should still be available since pattern was not found expect(client.read).to be == "hello world" end it "can discard with a limit" do server.write("hello\nworld\n") server.close # Use peek to verify initial buffer state expect(client.peek).to be == "hello\nworld\n" # Limit too small to find pattern - discards up to limit expect(client.discard_until("\n", limit: 4)).to be_nil # Use peek to verify that 4 bytes were discarded expect(client.peek).to be == "o\nworld\n" # After discarding 4 bytes, should find pattern in remaining data chunk = client.discard_until("\n", limit: 5) expect(chunk).not.to be_nil expect(chunk).to be(:end_with?, "\n") # Use peek to verify final buffer state expect(client.peek).to be == "world\n" expect(client.read).to be == "world\n" end it "handles patterns spanning buffer boundaries" do # Use a small block size to force the pattern to span boundaries client.block_size = 3 server.write("ab") server.flush server.write("cdef") server.close # Pattern "cd" spans the boundary between "ab" and "cdef" chunk = client.discard_until("cd") expect(chunk).not.to be_nil expect(chunk).to be(:end_with?, "cd") expect(client.read).to be == "ef" end it "handles large patterns efficiently" do large_pattern = "X" * 20 # Trigger sliding window logic server.write("some data before") server.write(large_pattern) server.write("some data after") server.close chunk = client.discard_until(large_pattern) expect(chunk).not.to be_nil expect(chunk).to be(:end_with?, large_pattern) expect(client.read).to be == "some data after" end with "1-byte block size" do it "can discard data with a multi-byte pattern" do server.write("hello\nworld\n") server.close client.block_size = 1 chunk1 = client.discard_until("\n") expect(chunk1).not.to be_nil expect(chunk1).to be(:end_with?, "\n") chunk2 = client.discard_until("\n") expect(chunk2).not.to be_nil expect(chunk2).to be(:end_with?, "\n") expect(client.discard_until("\n")).to be_nil end end end end ABidirectionalStream = Sus::Shared("a bidirectional stream") do with "#close_write" do it "can close the write side of the stream" do server.write("Hello World!") server.close_write expect(client.read).to be == "Hello World!" expect(client.eof?).to be_truthy client.write("Goodbye World!") client.close_write expect(server.read).to be == "Goodbye World!" expect(server.eof?).to be_truthy end end end ASocketStream = Sus::Shared("a socket stream") do with "#read" do it "should raise ConnectionResetError when connection is abruptly closed" do linger = [1, 0].pack("ii") io = server.io.to_io io.setsockopt(Socket::SOL_SOCKET, Socket::SO_LINGER, linger) io.close expect do client.read(4) end.to raise_exception(IO::Stream::ConnectionResetError) end end end describe "IO.pipe" do let(:pipe) {IO.pipe} let(:client) {IO::Stream::Buffered.wrap(pipe[0])} let(:server) {IO::Stream::Buffered.wrap(pipe[1])} def after(error = nil) pipe.each(&:close) super end it_behaves_like AUnidirectionalStream it "can close the writing end of the stream" do server.write("Oh yes!") server.close_write expect do client.write("Oh no!") client.flush end.to raise_exception(IOError, message: be =~ /not opened for writing/) end it "can close the reading end of the stream" do client.close_read expect do client.read end.to raise_exception(IOError, message: be =~ /closed stream/) end end describe "Socket.pair" do let(:sockets) {Socket.pair(:UNIX, :STREAM)} let(:client) {IO::Stream::Buffered.wrap(sockets[0])} let(:server) {IO::Stream::Buffered.wrap(sockets[1])} def after(error = nil) sockets.each(&:close) super end it_behaves_like AUnidirectionalStream it_behaves_like ABidirectionalStream end describe TCPSocket do include Sus::Fixtures::Async::ReactorContext before do server = TCPServer.new("localhost", 0) port = server.addr[1] @sockets = [ TCPSocket.new("localhost", port), server.accept ] @client = IO::Stream::Buffered.wrap(@sockets[0]) @server = IO::Stream::Buffered.wrap(@sockets[1]) end after do @sockets.each(&:close) end attr :client attr :server it_behaves_like AUnidirectionalStream it_behaves_like ABidirectionalStream it_behaves_like ASocketStream end describe OpenSSL::SSL::SSLSocket do include Sus::Fixtures::Async::ReactorContext include Sus::Fixtures::OpenSSL::VerifiedCertificateContext include Sus::Fixtures::OpenSSL::ValidCertificateContext before do server = TCPServer.new("localhost", 0) port = server.addr[1] @sockets = [ TCPSocket.new("localhost", port), server.accept ] client = OpenSSL::SSL::SSLSocket.new(@sockets[0], client_context) server = OpenSSL::SSL::SSLSocket.new(@sockets[1], server_context) client.sync_close = true server.sync_close = true [ Async {server.accept}, Async {client.connect} ].each(&:wait) @client = IO::Stream::Buffered.wrap(client) @server = IO::Stream::Buffered.wrap(server) end after do @sockets.each(&:close) end attr :client attr :server it_behaves_like AUnidirectionalStream it_behaves_like ABidirectionalStream end socketry-io-stream-81522e2/test/io/stream/buffered/000077500000000000000000000000001517460720600222005ustar00rootroot00000000000000socketry-io-stream-81522e2/test/io/stream/buffered/syswrite.md000066400000000000000000000205641517460720600244220ustar00rootroot00000000000000# Write / Close / Exception Handling If you use `@io.write` in `#syswrite` implementation, it's possible to invoke the gods of undefined behavior. ``` > write /home/samuel/Projects/socketry/io-stream/lib/io/stream/buffered.rb:74: [BUG] Segmentation fault at 0x0000000000000000 ruby 3.3.0 (2023-12-25 revision 5124f9ac75) [x86_64-linux] -- Control frame information ----------------------------------------------- c:0010 p:---- s:0042 e:000041 CFUNC :write c:0009 p:0007 s:0037 e:000036 METHOD /home/samuel/Projects/socketry/io-stream/lib/io/stream/buffered.rb:74 c:0008 p:0019 s:0031 e:000030 BLOCK /home/samuel/Projects/socketry/io-stream/lib/io/stream/generic.rb:140 [FINISH] c:0007 p:---- s:0028 e:000027 CFUNC :synchronize c:0006 p:0015 s:0024 e:000023 METHOD /home/samuel/Projects/socketry/io-stream/lib/io/stream/generic.rb:135 c:0005 p:0023 s:0020 e:000019 METHOD /home/samuel/Projects/socketry/io-stream/lib/io/stream/generic.rb:156 c:0004 p:0019 s:0015 e:000014 BLOCK test/io/stream/buffered/syswrite.rb:22 c:0003 p:0010 s:0012 e:000011 BLOCK /home/samuel/.gem/ruby/3.3.0/gems/async-2.10.2/lib/async/task.rb:163 c:0002 p:0008 s:0009 e:000007 BLOCK /home/samuel/.gem/ruby/3.3.0/gems/async-2.10.2/lib/async/task.rb:376 [FINISH] c:0001 p:---- s:0003 e:000002 DUMMY [FINISH] -- Ruby level backtrace information ---------------------------------------- /home/samuel/.gem/ruby/3.3.0/gems/async-2.10.2/lib/async/task.rb:376:in `block in schedule' /home/samuel/.gem/ruby/3.3.0/gems/async-2.10.2/lib/async/task.rb:163:in `block in run' test/io/stream/buffered/syswrite.rb:22:in `block (4 levels) in ' /home/samuel/Projects/socketry/io-stream/lib/io/stream/generic.rb:156:in `write' /home/samuel/Projects/socketry/io-stream/lib/io/stream/generic.rb:135:in `flush' /home/samuel/Projects/socketry/io-stream/lib/io/stream/generic.rb:135:in `synchronize' /home/samuel/Projects/socketry/io-stream/lib/io/stream/generic.rb:140:in `block in flush' /home/samuel/Projects/socketry/io-stream/lib/io/stream/buffered.rb:74:in `syswrite' /home/samuel/Projects/socketry/io-stream/lib/io/stream/buffered.rb:74:in `write' -- Threading information --------------------------------------------------- Total ractor count: 1 Ruby thread count for this ractor: 1 -- Machine register context ------------------------------------------------ RIP: 0x00007c92c3d14bf0 RBP: 0x00007c92c41d2760 RSP: 0x00007c92a92393d0 RAX: 0x000056b2b4875700 RBX: 0x3596c72fd367c900 RCX: 0x000056b2b427cb70 RDX: 0x00007c92c3865120 RDI: 0x0000000000000000 RSI: 0x00007c92c41d2760 R8: 0x0000000000000038 R9: 0x000056b2b4eda390 R10: 0x0000000000000003 R11: 0x00007c92a7f434e8 R12: 0x3596c72fd367c900 R13: 0x00007c92a7bd9be0 R14: 0x0000000d00000009 R15: 0x000056b2b427f0f0 EFL: 0x0000000000010206 -- C level backtrace information ------------------------------------------- /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(rb_print_backtrace+0x14) [0x7c92c3f1c6bb] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/vm_dump.c:820 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(rb_vm_bugreport) /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/vm_dump.c:1151 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(rb_bug_for_fatal_signal+0x100) [0x7c92c3d12ed0] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/error.c:1065 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(sigsegv+0x4b) [0x7c92c3e66dab] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/signal.c:926 /usr/lib/libc.so.6(0x7c92c38c8770) [0x7c92c38c8770] /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(displaying_class_of+0x1a) [0x7c92c3d14bf0] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/error.c:1182 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(RB_BUILTIN_TYPE) /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/error.c:1179 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(rbimpl_RB_TYPE_P_fastpath) ./include/ruby/internal/value_type.h:351 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(RB_TYPE_P) ./include/ruby/internal/value_type.h:378 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(rb_check_typeddata) /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/error.c:1315 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(mutex_ptr+0x5) [0x7c92c3eb293f] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/thread_sync.c:155 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(do_mutex_lock) /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/thread_sync.c:301 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(rb_multi_ractor_p+0x0) [0x7c92c3eb5dbd] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/thread.c:1674 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(rb_vm_lock_enter) ./vm_sync.h:74 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(thread_io_wake_pending_closer) /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/thread.c:1680 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(rb_thread_io_blocking_call) /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/thread.c:1764 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(rb_io_write_memory+0xa4) [0x7c92c3d57fe4] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/io.c:1322 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(io_binwrite_string+0x17e) [0x7c92c3d604ce] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/io.c:1747 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(rb_ensure+0x110) [0x7c92c3d1cb30] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/eval.c:1009 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(io_binwrite+0x140) [0x7c92c3d606e0] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/io.c:1872 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(io_fwrite+0x51) [0x7c92c3d608b2] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/io.c:1977 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(io_write) /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/io.c:2015 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(vm_cfp_consistent_p+0x0) [0x7c92c3eee989] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/vm_insnhelper.c:3490 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(vm_call_cfunc_with_frame_) /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/vm_insnhelper.c:3492 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(vm_call_cfunc_with_frame) /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/vm_insnhelper.c:3518 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(vm_call_cfunc_other) /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/vm_insnhelper.c:3544 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(vm_sendish+0xac) [0x7c92c3effe09] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/vm_insnhelper.c:5581 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(vm_exec_core) /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/insns.def:834 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(rb_vm_exec+0x179) [0x7c92c3f05db9] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/vm.c:2486 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(rb_yield+0xc2) [0x7c92c3f0b442] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/vm.c:1634 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(rb_ensure+0x110) [0x7c92c3d1cb30] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/eval.c:1009 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(vm_call_cfunc_with_frame_+0xd5) [0x7c92c3eee624] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/vm_insnhelper.c:3490 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(vm_call_cfunc_with_frame) /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/vm_insnhelper.c:3518 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(vm_sendish+0x160) [0x7c92c3ef4f00] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/vm_insnhelper.c:5581 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(vm_exec_core+0x2460) [0x7c92c3f02140] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/insns.def:814 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(rb_vm_exec+0x179) [0x7c92c3f05db9] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/vm.c:2486 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(rb_vm_invoke_proc+0x5e) [0x7c92c3f0b9fe] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/vm.c:1728 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(rb_fiber_start+0x19f) [0x7c92c3cee90f] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/cont.c:2536 /home/samuel/.rubies/ruby-3.3.0/lib/libruby.so.3.3(fiber_entry+0x1c) [0x7c92c3ceec5c] /tmp/ruby-build.20231228132516.30619.vhOi8F/ruby-3.3.0/cont.c:847 ```socketry-io-stream-81522e2/test/io/stream/buffered/syswrite.rb000066400000000000000000000013561517460720600244230ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024-2026, by Samuel Williams. require "io/stream/buffered" require "sus/fixtures/async/reactor_context" describe "IO.pipe" do include Sus::Fixtures::Async::ReactorContext let(:pipe) {IO.pipe} let(:client) {IO::Stream::Buffered.wrap(pipe[0])} let(:server) {IO::Stream::Buffered.wrap(pipe[1])} after do pipe.each(&:close) end it "can close while writing" do message = "." * 1024 * 128 task = reactor.async do loop do # $stderr.puts "-> write" server.write(message) # $stderr.puts "<- write" rescue IOError => error expect(error.message).to be =~ /closed/ break end end # Become a segfault: sleep 0.001 end end socketry-io-stream-81522e2/test/io/stream/duplex.rb000066400000000000000000000057351517460720600222560ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2026, by Samuel Williams. require "io/stream" describe IO::Stream::Duplex do def make_pipes server_input, client_output = IO.pipe client_input, server_output = IO.pipe { server_input: server_input, server_output: server_output, client_input: client_input, client_output: client_output, } end with ".new" do it "wraps distinct input and output streams" do pipes = make_pipes begin stream = subject.new(pipes[:client_input], pipes[:client_output]) expect(stream.input).to be_equal(pipes[:client_input]) expect(stream.output).to be_equal(pipes[:client_output]) expect(stream.input).not.to be_equal(stream.output) ensure pipes.each_value(&:close) end end end with "::Duplex" do it "wraps a single duplex IO directly" do io = StringIO.new stream = IO::Stream::Duplex(io) expect(stream).to be_a(IO::Stream::Buffered) expect(stream.io).to be_equal(io) end it "returns a buffered stream wrapping a duplex IO" do pipes = make_pipes begin stream = IO::Stream::Duplex(pipes[:client_input], pipes[:client_output]) expect(stream).to be_a(IO::Stream::Buffered) expect(stream.io).to be_a(subject) expect(stream.io.input).to be_equal(pipes[:client_input]) expect(stream.io.output).to be_equal(pipes[:client_output]) ensure stream&.close pipes.each_value.each do |io| io.close unless io.closed? end end end end with "#read and #write" do it "reads from input and writes to output" do pipes = make_pipes begin stream = IO::Stream::Duplex(pipes[:client_input], pipes[:client_output]) stream.write("hello") stream.flush expect(pipes[:server_input].read(5)).to be == "hello" pipes[:server_output].write("world") pipes[:server_output].close expect(stream.read(5)).to be == "world" ensure stream&.close rescue nil pipes.each_value.each do |io| io.close unless io.closed? end end end end with "#close_write" do it "closes only the write side" do pipes = make_pipes begin stream = IO::Stream::Duplex(pipes[:client_input], pipes[:client_output]) stream.write("hello") stream.close_write expect(pipes[:server_input].read).to be == "hello" expect(pipes[:client_input]).not.to be(:closed?) ensure stream.close_read rescue nil pipes.each_value.each do |io| io.close unless io.closed? end end end end with "#close" do it "closes both sides when input and output are distinct" do pipes = make_pipes begin stream = IO::Stream::Duplex(pipes[:client_input], pipes[:client_output]) stream.close expect(stream).to be(:closed?) expect(pipes[:client_input]).to be(:closed?) expect(pipes[:client_output]).to be(:closed?) ensure pipes.each_value.each do |io| io.close unless io.closed? end end end end end socketry-io-stream-81522e2/test/io/stream/generic.rb000066400000000000000000000013561517460720600223640ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require "io/stream/generic" describe IO::Stream::Generic do let(:stream) {subject.new} with "#closed?" do it "should return false by default" do expect(stream.closed?).to be_falsey end end with "#read" do it "should raise NotImplementedError" do expect{stream.read(10)}.to raise_exception(NotImplementedError) end end with "#flush" do it "should raise NotImplementedError" do expect{stream.write("hello"); stream.flush}.to raise_exception(NotImplementedError) end end with "#close" do it "should raise NotImplementedError" do expect{stream.close}.to raise_exception(NotImplementedError) end end end socketry-io-stream-81522e2/test/io/stream/performance.rb000066400000000000000000000013701517460720600232450ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024, by Samuel Williams. require "io/stream/buffered" require "async/clock" describe IO::Stream::Buffered do with "performance (BLOCK_SIZE: #{IO::Stream::BLOCK_SIZE} MAXIMUM_READ_SIZE: #{IO::Stream::MAXIMUM_READ_SIZE})" do let(:stream) {subject.open("/dev/zero")} after do stream.close end it "can read data quickly" do data = nil duration = Async::Clock.measure do data = stream.read(1024**3) # Compare with: # data = stream.io.read(1024**3) end size = data.bytesize / 1024**2 rate = size / duration inform "Read #{size.round(2)}MB of data at #{rate.round(2)}MB/s." expect(rate).to be > 128 end end end socketry-io-stream-81522e2/test/io/stream/shim/000077500000000000000000000000001517460720600213565ustar00rootroot00000000000000socketry-io-stream-81522e2/test/io/stream/shim/buffered.rb000066400000000000000000000032301517460720600234630ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2025, by Samuel Williams. require "io/stream/shim/buffered" describe IO do let(:io) {IO.new(IO.sysopen("/dev/null", "w"))} it "should be buffered by default" do expect(io).to be(:buffered?) end it "should be able to set buffering" do io.buffered = false expect(io).not.to be(:buffered?) end end describe TCPSocket do let(:client) {@client = TCPSocket.new("localhost", @server.local_address.ip_port)} def before @server = TCPServer.new("localhost", 0) end def after(error = nil) @server.close @client&.close super end it "should not be buffered by default" do expect(client).not.to be(:buffered?) end it "should be able to unset buffering" do client.buffered = false expect(client).not.to be(:buffered?) end it "should be able to set buffering" do client.buffered = true expect(client).to be(:buffered?) end end describe UNIXSocket do let(:sockets) {UNIXSocket.pair} let(:client) {sockets[0]} let(:server) {sockets[1]} def after(error = nil) client.close server.close super end it "should not be buffered by default" do expect(client).not.to be(:buffered?) end it "should be able to unset buffering" do client.buffered = false expect(client).not.to be(:buffered?) end it "should be able to set buffering" do client.buffered = true expect(client).to be(:buffered?) end end describe StringIO do let(:io) {StringIO.new} it "should not be buffered by default" do expect(io).not.to be(:buffered?) end it "is always unbuffered" do io.buffered = true expect(io).not.to be(:buffered?) end end socketry-io-stream-81522e2/test/io/stream/shim/readable.rb000066400000000000000000000030311517460720600234370ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024-2025, by Samuel Williams. require "io/stream/shim/readable" describe IO do let(:io) {IO.new(IO.sysopen("/dev/null", "w"))} it "should be readable" do expect(io).to be(:readable?) end it "should not be readable after closing" do io.close expect(io).not.to be(:readable?) end end describe TCPSocket do let(:client) {@client} attr :server def before @server = TCPServer.new("localhost", 0) @client = TCPSocket.new("localhost", @server.local_address.ip_port) end def after(error = nil) @server.close @client&.close super end it "should be readable" do expect(client).to be(:readable?) end it "should not be readable after closing" do client.close expect(client).not.to be(:readable?) end it "should not be readable after closing server" do server.close expect(client).not.to be(:readable?) end end describe StringIO do let(:io) {StringIO.new} with "empty buffer" do it "should not be readable" do expect(io).not.to be(:readable?) end end with "non-empty buffer" do it "should be readable" do io.write("Hello, World!") io.rewind expect(io).to be(:readable?) end it "should be readable after reading" do io.write("Hello, World!") io.rewind io.read(5) expect(io).to be(:readable?) end it "should not be readable after reading all" do io.write("Hello, World!") io.rewind io.read expect(io).not.to be(:readable?) end end end socketry-io-stream-81522e2/test/io/stream/string_buffer.rb000066400000000000000000000015271517460720600236070ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024, by Samuel Williams. require "io/stream/string_buffer" describe IO::Stream::StringBuffer do let(:string_buffer) {subject.new} it "should be a subclass of String" do expect(subject).to be < String end it "should have a binary encoding" do expect(string_buffer.encoding).to be == Encoding::BINARY end it "should append unicode strings" do string_buffer << (+"Hello, World!").force_encoding(Encoding::UTF_8) expect(string_buffer).to be == "Hello, World!" expect(string_buffer.encoding).to be == Encoding::BINARY end it "should append binary strings" do string_buffer << (+"Hello, World!").force_encoding(Encoding::BINARY) expect(string_buffer).to be == "Hello, World!" expect(string_buffer.encoding).to be == Encoding::BINARY end end