From b361987f046d294fb272634d9b120e0a2d39bee0 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 25 Sep 2026 10:15:25 +1200 Subject: [PATCH] Preserve underlying body read errors --- lib/protocol/grpc/body/readable.rb | 11 ++----- releases.md | 4 +++ test/protocol/grpc/body/readable.rb | 45 ++++++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/lib/protocol/grpc/body/readable.rb b/lib/protocol/grpc/body/readable.rb index ff47712..b21ed4a 100644 --- a/lib/protocol/grpc/body/readable.rb +++ b/lib/protocol/grpc/body/readable.rb @@ -48,6 +48,7 @@ def initialize(body, message_class: nil, encoding: nil) # Read the next gRPC message. # Overrides Wrapper#read to transform raw HTTP body chunks into decoded gRPC messages. + # Errors raised by the underlying body propagate unchanged. # @returns [Object | String | Nil] Decoded message, raw binary, or `Nil` if stream ended def read # Read 5-byte prefix: 1 byte compression flag + 4 bytes length @@ -85,14 +86,8 @@ def read def read_exactly(n) # Fill buffer until we have enough data: while @buffer.bytesize < n - if @body.nil? || @body.empty? - return nil if @buffer.empty? - - raise Error.new(Status::INTERNAL, "Truncated gRPC frame: expected #{n} bytes, received #{@buffer.bytesize}") - end - - # Read chunk from underlying body: - chunk = @body.read + # An empty body can still have a pending error, so read to determine EOF: + chunk = @body&.read if chunk.nil? return nil if @buffer.empty? diff --git a/releases.md b/releases.md index 5f25702..9279550 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Preserve underlying body read errors, including failures before a message, during a partial frame, or while finishing the stream. These failures are no longer hidden as EOF or replaced with a truncated-frame error. + ## v0.16.0 - Preserve timeout precision within the eight-digit wire limit, rounding up when necessary. diff --git a/test/protocol/grpc/body/readable.rb b/test/protocol/grpc/body/readable.rb index 9b6e3cf..1c6cf8e 100644 --- a/test/protocol/grpc/body/readable.rb +++ b/test/protocol/grpc/body/readable.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2025, by Samuel Williams. +# Copyright, 2025-2026, by Samuel Williams. require "protocol/grpc/body/readable" require "protocol/http/body/buffered" +require "protocol/http/body/writable" require_relative "../../../../fixtures/protocol/grpc/test_message" require "zlib" @@ -73,6 +74,48 @@ def write_data(data, compressed: false) expect(body.read).to be_nil end + it "returns nil when there is no underlying body" do + expect(subject.new(nil).read).to be_nil + end + + with "pending body errors" do + let(:source_body) {Protocol::HTTP::Body::Writable.new} + let(:error) {IOError.new("Stream failed!")} + + it "propagates an error even when the source body is empty" do + source_body.close_write(error) + + expect(source_body).to be(:empty?) + expect{body.read}.to raise_exception(IOError).and(be_equal(error)) + end + + { + "partial prefix" => "\x00\x00".b, + "missing payload" => "\x00".b + [2].pack("N"), + "partial payload" => "\x00".b + [2].pack("N") + "a", + }.each do |description, chunk| + it "preserves the source error after a #{description}" do + source_body.write(chunk) + mock(source_body) do |wrapper| + wrapper.wrap(:read) do |original| + original.call.tap{source_body.close_write(error)} + end + end + + expect{body.read}.to raise_exception(IOError).and(be_equal(error)) + end + end + + it "propagates an error while finishing after a complete message" do + message = message_class.new(value: "Hello") + write_message(message) + expect(body.read).to be == message + source_body.close_write(error) + + expect{body.finish}.to raise_exception(IOError).and(be_equal(error)) + end + end + it "works with binary mode (no message_class)" do binary_body = subject.new(source_body, message_class: nil) data = "Hello World".dup.force_encoding(Encoding::BINARY)