diff --git a/context/message-body.md b/context/message-body.md index c26606c..319ada6 100644 --- a/context/message-body.md +++ b/context/message-body.md @@ -259,6 +259,23 @@ content = uppercase.join # => "HELLO WORLD" ## Life-cycle +Bodies model application-facing streams. Their close operations describe what the application will do next; protocol implementations are responsible for mapping those operations to the wire protocol safely. + +### Directional Closure + +Request and response bodies are independent, so a bidirectional {ruby Protocol::HTTP::Body::Stream} can close either direction without implicitly closing the other: + +| Operation | Application-level meaning | Typical protocol consequence | +| --- | --- | --- | +| `read` returns `nil` | The peer or producer completed the input normally. | The inbound body has reached end-of-stream. | +| `close_read` before end-of-stream | The application will not consume the remaining input, but may continue writing. | Discard unread data, terminate the exchange, or make the connection non-reusable. | +| `close_write` without an error | The application has finished producing output, but may continue reading. | Preserve previously written data and send a normal end-of-stream. | +| `close` without an error | The application has finished with both directions. | Complete or terminate the exchange without reporting an application error. | +| `close` with an error | The application cannot continue the exchange successfully. | Propagate the error or terminate the exchange using an appropriate protocol error. | +| `discard` | Consume input without processing it. | Prefer preserving the exchange or connection for reuse. | + +These operations update local application-facing state. Returning from a close operation does not guarantee that the peer has observed it or that the underlying transport has been closed synchronously. Those details depend on the protocol and may be completed later. + ### Initialization Bodies are typically initialized with the data they need to process. For example: diff --git a/guides/message-body/readme.md b/guides/message-body/readme.md index c26606c..319ada6 100644 --- a/guides/message-body/readme.md +++ b/guides/message-body/readme.md @@ -259,6 +259,23 @@ content = uppercase.join # => "HELLO WORLD" ## Life-cycle +Bodies model application-facing streams. Their close operations describe what the application will do next; protocol implementations are responsible for mapping those operations to the wire protocol safely. + +### Directional Closure + +Request and response bodies are independent, so a bidirectional {ruby Protocol::HTTP::Body::Stream} can close either direction without implicitly closing the other: + +| Operation | Application-level meaning | Typical protocol consequence | +| --- | --- | --- | +| `read` returns `nil` | The peer or producer completed the input normally. | The inbound body has reached end-of-stream. | +| `close_read` before end-of-stream | The application will not consume the remaining input, but may continue writing. | Discard unread data, terminate the exchange, or make the connection non-reusable. | +| `close_write` without an error | The application has finished producing output, but may continue reading. | Preserve previously written data and send a normal end-of-stream. | +| `close` without an error | The application has finished with both directions. | Complete or terminate the exchange without reporting an application error. | +| `close` with an error | The application cannot continue the exchange successfully. | Propagate the error or terminate the exchange using an appropriate protocol error. | +| `discard` | Consume input without processing it. | Prefer preserving the exchange or connection for reuse. | + +These operations update local application-facing state. Returning from a close operation does not guarantee that the peer has observed it or that the underlying transport has been closed synchronously. Those details depend on the protocol and may be completed later. + ### Initialization Bodies are typically initialized with the data they need to process. For example: diff --git a/lib/protocol/http/body/readable.rb b/lib/protocol/http/body/readable.rb index aa03731..c06a21a 100644 --- a/lib/protocol/http/body/readable.rb +++ b/lib/protocol/http/body/readable.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2019-2024, by Samuel Williams. +# Copyright, 2019-2026, by Samuel Williams. # Copyright, 2023, by Bruno Sutic. require_relative "stream" @@ -22,7 +22,11 @@ module Body class Readable # Close the stream immediately. After invoking this method, the stream should be considered closed, and all internal resources should be released. # - # If an error occured while handling the output, it can be passed as an argument. This may be propagated to the client, for example the client may be informed that the stream was not fully read correctly. + # Closing the stream before it reaches end-of-file abandons any remaining input. The protocol implementation must account for that unread data before the associated exchange or connection can be reused. Depending on the protocol, it may discard the remaining data, terminate the exchange, or make the connection non-reusable. Use {discard} when preserving the exchange or connection is preferred. + # + # When closing before end-of-file, omitting the error represents deliberate application-level abandonment, not a protocol failure. + # + # If an error occurred while handling the output, it can be passed as an argument. This may be propagated to the client, for example the client may be informed that the stream was not fully read correctly. # # Invoking {read} after {close} will return `nil`. # diff --git a/lib/protocol/http/body/stream.rb b/lib/protocol/http/body/stream.rb index 72ba02e..ec445b4 100644 --- a/lib/protocol/http/body/stream.rb +++ b/lib/protocol/http/body/stream.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2019-2025, by Samuel Williams. +# Copyright, 2019-2026, by Samuel Williams. # Copyright, 2023, by Genki Takiuchi. # Copyright, 2025, by William T. Nelson. @@ -347,7 +347,13 @@ def puts(*arguments, separator: NEWLINE) def flush end - # Close the input body. + # Close the application-facing input body. This does not close the output body, which may continue to be written independently. + # + # If the input has not reached end-of-file, any remaining data is abandoned. The protocol implementation must ensure that unread data cannot interfere with subsequent exchanges. Depending on the protocol, it may discard the remaining data, terminate the current exchange, or make the connection non-reusable. + # + # Closing without an error represents orderly application-level abandonment, not a protocol failure. + # + # This method is idempotent. After the first call, subsequent calls have no effect. # # If, while processing the data that was read from this stream, an error is encountered, it should be passed to this method. # @@ -362,7 +368,11 @@ def close_read(error = nil) end end - # Close the output body. + # Close the application-facing output body. This does not close the input body, which may continue to be read independently. + # + # Closing without an error indicates that no more output will be produced. Previously written data remains part of the output and should be followed by a normal end-of-stream from the protocol implementation. If an error is provided, the protocol implementation may terminate the exchange instead. + # + # This method is idempotent. After the first call, subsequent calls have no effect. # # If, while generating the data that is written to this stream, an error is encountered, it should be passed to this method. # @@ -377,6 +387,10 @@ def close_write(error = nil) # Close the input and output bodies. # + # Closing without an error represents orderly completion or abandonment of both application-facing directions, not cancellation. If the peer has not completed the exchange, the protocol implementation may need to terminate it without reporting an application error. + # + # Repeated calls are safe; each underlying direction will be closed at most once. + # # @parameter error [Exception | Nil] The error that caused this stream to be closed, if any. def close(error = nil) self.close_read(error) diff --git a/lib/protocol/http/body/streamable.rb b/lib/protocol/http/body/streamable.rb index 20e95af..bf25547 100644 --- a/lib/protocol/http/body/streamable.rb +++ b/lib/protocol/http/body/streamable.rb @@ -15,7 +15,7 @@ module Body # # In some cases, it's advantageous to directly read and write to the underlying stream if possible. For example, HTTP/1 upgrade requests, WebSockets, and similar. To handle that case, response bodies can implement {stream?} and return `true`. When {stream?} returns true, the body **should** be consumed by calling `call(stream)`. Server implementations may choose to always invoke `call(stream)` if it's efficient to do so. Bodies that don't support it will fall back to using {each}. # - # When invoking `call(stream)`, the stream can be read from and written to, and closed. However, the stream is only guaranteed to be open for the duration of the `call(stream)` call. Once the method returns, the stream **should** be closed by the server. + # Invoking `call(stream)` transfers ownership of the stream to the called body. The body may read from and write to the stream, and **must** close it before returning. The caller must consider the stream closed and unusable after `call(stream)` returns. module Streamable # Generate a new streaming request body using the given block to generate the body. # @@ -113,7 +113,7 @@ def read @output.read end - # Invoke the block with the given stream. The block can read and write to the stream, and must close the stream when finishing. + # Invoke the block with the given stream. The block owns the stream for the duration of the call and must close it before returning. # # @parameter stream [Stream] The stream to read and write to. def call(stream) diff --git a/releases.md b/releases.md index 253e346..d9e5b16 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Clarified body stream lifecycle and ownership, including the directional semantics of `Protocol::HTTP::Body::Stream#close_read`, `#close_write`, and `#close`, how premature input closure affects the associated HTTP exchange, and ownership of streams passed to `Streamable#call`. + ## v0.71.0 - Parse all cookie pairs from `Cookie` header fields, including multiple semicolon-separated pairs within each field.