protocol-http-executor 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 74740e7516b0bed60f7da5365c618fea04a7e19eb0d29d4414bb117a483de0b4
4
+ data.tar.gz: dae71f98528888d7a4857acf3769a2c677bee5e911f78d8ecd7832039c33bc51
5
+ SHA512:
6
+ metadata.gz: 8209ce922552055eaff122135e83a851b94a5db805c828f2e2a3972e28670bec04a5dda3da8f516cd7ec99843d2be84cfc07dfab3b3f2ea48fa04950f5ded9c1
7
+ data.tar.gz: a76a03945495577d6878f1e7a2638211c074cb68954087d89e91f0c32d3483023e84c77adc9a28d0de20905b586aa5f50539bbd64bcd1f90265a38da752de973
@@ -0,0 +1,58 @@
1
+ # Design Overview
2
+
3
+ This guide explains how `protocol-http-executor` preserves HTTP request, response, streaming, error, and trailer semantics across an execution boundary.
4
+
5
+ ## Execution Boundary
6
+
7
+ Thread and Ractor execution use the same worker and transport protocol. Only the mechanism which creates the isolated execution context differs. Keeping this boundary common allows a future fixed-size pool to dispatch the same per-request transport to an idle worker.
8
+
9
+ Each request receives two connected Unix socket pairs:
10
+
11
+ | Channel | Direction | Purpose |
12
+ |---|---|---|
13
+ | Control | Bidirectional | Request and response heads, interim responses, consumption mode, and cancellation. |
14
+ | Body | Bidirectional | Request and upgraded-stream input flows to the worker; response chunks, trailers, and post-head errors flow to the caller. |
15
+
16
+ Separating control and body traffic prevents control events from being delayed by body frames. The full-duplex body socket allows request input and response output to flow concurrently, while `shutdown(SHUT_WR)` represents the final EOF independently in each direction. Socket buffering provides bounded operating-system backpressure and integrates with the Ruby fiber scheduler.
17
+
18
+ ## Request and Response Phases
19
+
20
+ ``` mermaid
21
+ sequenceDiagram
22
+ participant Caller
23
+ participant Worker
24
+ participant Application
25
+ Caller->>Worker: Request head
26
+ par Request input
27
+ Caller->>Worker: Body chunks, trailers, EOF
28
+ and Application execution
29
+ Worker->>Application: Reconstructed request
30
+ Application-->>Worker: Interim responses
31
+ Worker-->>Caller: Interim responses
32
+ Application->>Worker: Response
33
+ end
34
+ Worker->>Caller: Response head
35
+ Caller->>Worker: Read or direct-stream mode
36
+ Worker->>Caller: Body chunks and trailers
37
+ Worker-->>Caller: Shutdown write direction
38
+ ```
39
+
40
+ Request and response heads contain their initial header fields only. Trailer fields remain delayed until the corresponding body reaches EOF. Empty bodies are omitted so the application observes a normal `nil` body rather than a synthetic stream.
41
+
42
+ ## Direct Duplex Streaming
43
+
44
+ A normal response body is consumed by reading chunks. A streamable response body can instead be called with a duplex stream. The caller then forwards data in both directions:
45
+
46
+ - The input task completes any initial request body before reading upgraded-stream input.
47
+ - The worker exposes both phases as one readable stream to the application.
48
+ - Response output uses the opposite direction of the full-duplex body socket and can progress independently.
49
+
50
+ The initial request-body boundary remains an explicit event because upgraded-stream input can follow it. Once no more data can follow in a direction, the sender shuts down that socket direction and the receiver observes a normal EOF.
51
+
52
+ This ordering preserves the HTTP message boundary while still supporting protocols which take over the connection after the response head.
53
+
54
+ ## Errors and Closure
55
+
56
+ Errors before the response head are reported on the control channel and cause the middleware call to fail. Errors after the response head are reported on the output channel and are raised when the response body is consumed.
57
+
58
+ Closing an unfinished response closes all transport channels and cancels caller-side forwarding tasks. Transport closure interrupts workers blocked on body IO. Applications which never yield or perform IO remain subject to the lifecycle limitations of their underlying thread or Ractor.
@@ -0,0 +1,69 @@
1
+ # Getting Started
2
+
3
+ This guide explains how to execute `Protocol::HTTP` applications in isolated threads or Ractors.
4
+
5
+ ## Installation
6
+
7
+ Add the gem to your project:
8
+
9
+ ~~~ bash
10
+ $ bundle add protocol-http-executor
11
+ ~~~
12
+
13
+ ## Core Concepts
14
+
15
+ `protocol-http-executor` provides middleware which reconstructs each request inside an isolated execution context and reconstructs the resulting response for the caller.
16
+
17
+ - {ruby Protocol::HTTP::Executor::Threaded} creates one native thread per request.
18
+ - {ruby Protocol::HTTP::Executor::Ractored} creates one Ractor per request using the Ruby head/4.1 Ractor API.
19
+ - {ruby Protocol::HTTP::Executor::Execution} forwards request and response bodies while preserving HTTP message semantics.
20
+
21
+ Calls must run inside an Async task. This allows request input, response output, and upgraded duplex streams to make progress concurrently.
22
+
23
+ ## Thread Execution
24
+
25
+ Use thread execution when an application or one of its dependencies can block the current event-loop thread. The application is shared between worker threads, so it must be thread-safe.
26
+
27
+ ``` ruby
28
+ require "async"
29
+ require "protocol/http/executor"
30
+
31
+ application = Protocol::HTTP::Middleware::HelloWorld
32
+ executor = Protocol::HTTP::Executor::Threaded.new(application)
33
+
34
+ begin
35
+ Sync do
36
+ request = Protocol::HTTP::Request["GET", "/"]
37
+ response = executor.call(request)
38
+
39
+ puts response.read
40
+ end
41
+ ensure
42
+ executor.close
43
+ end
44
+ ```
45
+
46
+ ## Ractor Execution
47
+
48
+ Use Ractor execution to isolate mutable Ruby objects and enable parallel Ruby execution where the application supports it. The application object must be shareable.
49
+
50
+ ``` ruby
51
+ module Application
52
+ def self.call(request)
53
+ Protocol::HTTP::Response[200, body: ["Hello World"]]
54
+ end
55
+
56
+ def self.close
57
+ end
58
+ end
59
+
60
+ executor = Protocol::HTTP::Executor::Ractored.new(Application)
61
+ ```
62
+
63
+ Ractor execution makes the default `Protocol::HTTP::Headers` policy shareable before creating workers. Custom application state and dependencies must independently satisfy Ractor isolation requirements.
64
+
65
+ ## Streaming and Trailers
66
+
67
+ Non-empty request and response bodies are forwarded one chunk at a time. Trailer fields are forwarded after the last body chunk and applied to the receiving headers before EOF is returned.
68
+
69
+ When a response body reports `stream?`, calling its `call(stream)` method enables direct duplex forwarding. Initial request-body chunks are delivered first, followed by upgraded-stream input.
@@ -0,0 +1,18 @@
1
+ # Automatically generated context index for Utopia::Project guides.
2
+ # Do not edit then files in this directory directly, instead edit the guides and then run `bake utopia:project:agent:context:update`.
3
+ ---
4
+ description: Execute Protocol::HTTP applications across isolated execution contexts.
5
+ metadata:
6
+ bug_tracker_uri: https://github.com/socketry/protocol-http-executor/issues
7
+ changelog_uri: https://github.com/socketry/protocol-http-executor/blob/main/releases.md
8
+ documentation_uri: https://socketry.github.io/protocol-http-executor/
9
+ source_code_uri: https://github.com/socketry/protocol-http-executor.git
10
+ files:
11
+ - path: getting-started.md
12
+ title: Getting Started
13
+ description: This guide explains how to execute `Protocol::HTTP` applications in
14
+ isolated threads or Ractors.
15
+ - path: design-overview.md
16
+ title: Design Overview
17
+ description: This guide explains how `protocol-http-executor` preserves HTTP request,
18
+ response, streaming, error, and trailer semantics across an execution boundary.
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "protocol/http/body/readable"
7
+
8
+ module Protocol
9
+ module HTTP
10
+ module Executor
11
+ # @namespace
12
+ module Body
13
+ # A request body reconstructed from input channel events.
14
+ class Input < ::Protocol::HTTP::Body::Readable
15
+ # Initialize an input body.
16
+ #
17
+ # @parameter channel [Channel] The input channel.
18
+ # @parameter headers [Protocol::HTTP::Headers] The request headers to receive trailers.
19
+ # @parameter metadata [Hash] The original body metadata.
20
+ def initialize(channel, headers, metadata)
21
+ @channel = channel
22
+ @headers = headers
23
+ @length = metadata[:length]
24
+ @stream = metadata[:stream]
25
+ @finished = false
26
+ @closed = false
27
+ end
28
+
29
+ # @attribute [Integer | Nil] The original body length.
30
+ attr :length
31
+
32
+ # @returns [Boolean] Whether the original body preferred streaming.
33
+ def stream?
34
+ @stream
35
+ end
36
+
37
+ # @returns [Boolean] Whether the body has finished.
38
+ def empty?
39
+ @finished
40
+ end
41
+
42
+ # Read the next request body chunk.
43
+ #
44
+ # @returns [String | Nil] The next chunk, or `nil` after body completion.
45
+ def read
46
+ return nil if @closed || @finished
47
+
48
+ loop do
49
+ message = @channel.read
50
+ raise ClosedError unless message
51
+
52
+ type, payload = message
53
+
54
+ case type
55
+ when :chunk
56
+ return payload
57
+ when :trailers
58
+ apply_trailers(payload)
59
+ when :end
60
+ @finished = true
61
+ return nil
62
+ when :error
63
+ @finished = true
64
+ raise RemoteError.new(payload)
65
+ else
66
+ raise ClosedError, "Unexpected input event: #{type.inspect}!"
67
+ end
68
+ end
69
+ end
70
+
71
+ # Close the request body.
72
+ #
73
+ # A body closed after EOF leaves the channel available for a subsequent upgraded stream.
74
+ def close(error = nil)
75
+ return if @closed
76
+
77
+ @closed = true
78
+ @channel.close_read unless @finished
79
+ end
80
+
81
+ private
82
+
83
+ def apply_trailers(fields)
84
+ @headers.trailer!
85
+ fields.each do |key, value|
86
+ @headers.add(key, value, trailer: true)
87
+ end
88
+ end
89
+ end
90
+
91
+ # An upgraded stream input that follows the initial request body phase.
92
+ class StreamInput < ::Protocol::HTTP::Body::Readable
93
+ # Initialize a stream input.
94
+ #
95
+ # @parameter channel [Channel] The input channel.
96
+ # @parameter headers [Protocol::HTTP::Headers] The request headers to receive trailers.
97
+ def initialize(channel, headers)
98
+ @channel = channel
99
+ @headers = headers
100
+ @closed = false
101
+ end
102
+
103
+ # Read the next request or upgraded-stream chunk.
104
+ def read
105
+ return nil if @closed
106
+
107
+ loop do
108
+ message = @channel.read
109
+ unless message
110
+ @closed = true
111
+ return nil
112
+ end
113
+
114
+ type, payload = message
115
+
116
+ case type
117
+ when :chunk, :stream_chunk
118
+ return payload
119
+ when :trailers
120
+ @headers.trailer!
121
+ payload.each{|key, value| @headers.add(key, value, trailer: true)}
122
+ when :end
123
+ # The request body ended; an upgraded stream may follow:
124
+ next
125
+ when :error, :stream_error
126
+ @closed = true
127
+ raise RemoteError.new(payload)
128
+ else
129
+ raise ClosedError, "Unexpected stream input event: #{type.inspect}!"
130
+ end
131
+ end
132
+ end
133
+
134
+ # Close the stream input channel.
135
+ def close(error = nil)
136
+ return if @closed
137
+
138
+ @closed = true
139
+ @channel.close_read
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "protocol/http/body/readable"
7
+
8
+ module Protocol
9
+ module HTTP
10
+ module Executor
11
+ module Body
12
+ # A response body read from the worker output channel.
13
+ class Output < ::Protocol::HTTP::Body::Readable
14
+ # Initialize a remote response body.
15
+ #
16
+ # @parameter execution [Execution] The owning execution.
17
+ # @parameter headers [Protocol::HTTP::Headers] The response headers to receive trailers.
18
+ # @parameter metadata [Hash] The remote response body metadata.
19
+ def initialize(execution, headers, metadata)
20
+ @execution = execution
21
+ @headers = headers
22
+ @length = metadata[:length]
23
+ @stream = metadata[:stream]
24
+ @mode = nil
25
+ @finished = false
26
+ end
27
+
28
+ # @attribute [Integer | Nil] The remote response body length.
29
+ attr :length
30
+
31
+ # @returns [Boolean] Whether the remote body prefers direct streaming.
32
+ def stream?
33
+ @stream
34
+ end
35
+
36
+ # @returns [Boolean] Whether the remote body has finished.
37
+ def empty?
38
+ @finished
39
+ end
40
+
41
+ # Read the next response body chunk.
42
+ def read
43
+ return nil if @finished
44
+
45
+ start(:read)
46
+ return read_output
47
+ end
48
+
49
+ # Stream the remote body directly through the given duplex stream.
50
+ def call(stream)
51
+ unless stream?
52
+ return super(stream)
53
+ end
54
+
55
+ start(:stream)
56
+ input_task = @execution.stream_input(stream)
57
+ error = nil
58
+
59
+ begin
60
+ while chunk = read_output
61
+ stream.write(chunk)
62
+ stream.flush
63
+ end
64
+ rescue => error
65
+ @execution.cancel(error)
66
+ raise
67
+ ensure
68
+ input_task&.cancel
69
+ stream.close(error)
70
+ @execution.finish unless error
71
+ end
72
+ end
73
+
74
+ # Close the response body and cancel unfinished execution.
75
+ def close(error = nil)
76
+ unless @finished
77
+ @finished = true
78
+ @execution.cancel(error)
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def start(mode)
85
+ if @mode && @mode != mode
86
+ raise IOError, "The response body is already being consumed using #{@mode.inspect}!"
87
+ end
88
+
89
+ unless @mode
90
+ @mode = mode
91
+ @execution.control.write(:consume, mode)
92
+ @execution.close_input if mode == :read
93
+ end
94
+ end
95
+
96
+ def read_output
97
+ loop do
98
+ message = @execution.body.read
99
+ unless message
100
+ @finished = true
101
+ @execution.finish
102
+ return nil
103
+ end
104
+
105
+ type, payload = message
106
+
107
+ case type
108
+ when :chunk
109
+ return payload
110
+ when :trailers
111
+ apply_trailers(payload)
112
+ when :error
113
+ @finished = true
114
+ @execution.finish
115
+ raise RemoteError.new(payload)
116
+ else
117
+ raise ClosedError, "Unexpected output event: #{type.inspect}!"
118
+ end
119
+ end
120
+ end
121
+
122
+ def apply_trailers(fields)
123
+ @headers.trailer!
124
+ fields.each do |key, value|
125
+ @headers.add(key, value, trailer: true)
126
+ end
127
+ end
128
+ end
129
+
130
+ # A writable output used while executing a streamable body in the worker.
131
+ class StreamOutput
132
+ # Initialize a stream output.
133
+ #
134
+ # @parameter channel [Channel] The worker output channel.
135
+ def initialize(channel)
136
+ @channel = channel
137
+ @closed = false
138
+ end
139
+
140
+ # Write an output chunk.
141
+ def write(chunk)
142
+ raise IOError, "The stream output is closed!" if @closed
143
+
144
+ @channel.write(:chunk, chunk)
145
+ return chunk.bytesize
146
+ end
147
+
148
+ alias << write
149
+
150
+ # Flush pending output.
151
+ def flush
152
+ end
153
+
154
+ # Close the stream output for writing.
155
+ def close_write(error = nil)
156
+ @closed = true
157
+ end
158
+
159
+ # Close the stream output for writing.
160
+ def close(error = nil)
161
+ close_write(error)
162
+ end
163
+
164
+ # @returns [Boolean] Whether the output has been closed.
165
+ def closed?
166
+ @closed
167
+ end
168
+
169
+ # @returns [Boolean] Whether the output contains pending chunks.
170
+ def empty?
171
+ true
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "socket"
7
+
8
+ module Protocol
9
+ module HTTP
10
+ module Executor
11
+ # A framed, bidirectional message channel over an `IO` object.
12
+ class Channel
13
+ HEADER_FORMAT = "N"
14
+ HEADER_SIZE = 4
15
+ MAXIMUM_FRAME_SIZE = 256 * 1024 * 1024
16
+
17
+ # Initialize a channel over the given IO object.
18
+ #
19
+ # @parameter io [IO] The connected stream.
20
+ def initialize(io)
21
+ @io = io
22
+ @write_mutex = Mutex.new
23
+ @read_closed = false
24
+ @write_closed = false
25
+ end
26
+
27
+ # Send a typed message.
28
+ #
29
+ # @parameter type [Symbol] The message type.
30
+ # @parameter payload [Object] The message payload.
31
+ def write(type, payload = nil)
32
+ data = Marshal.dump([type, payload])
33
+
34
+ if data.bytesize > MAXIMUM_FRAME_SIZE
35
+ raise ArgumentError, "Frame is too large: #{data.bytesize} bytes!"
36
+ end
37
+
38
+ frame = [data.bytesize].pack(HEADER_FORMAT) << data
39
+
40
+ @write_mutex.synchronize do
41
+ raise ClosedError if @write_closed
42
+
43
+ write_all(frame)
44
+ end
45
+
46
+ return nil
47
+ rescue IOError, SystemCallError => error
48
+ raise ClosedError, error.message
49
+ end
50
+
51
+ # Read the next typed message.
52
+ #
53
+ # @returns [Array(Symbol, Object) | Nil] The next message, or `nil` after a clean close.
54
+ def read
55
+ return nil if @read_closed
56
+
57
+ header = read_exactly(HEADER_SIZE, eof: true)
58
+ unless header
59
+ @read_closed = true
60
+ return nil
61
+ end
62
+
63
+ length = header.unpack1(HEADER_FORMAT)
64
+ if length > MAXIMUM_FRAME_SIZE
65
+ raise ClosedError, "Invalid frame size: #{length} bytes!"
66
+ end
67
+
68
+ return Marshal.load(read_exactly(length))
69
+ rescue EOFError, IOError, SystemCallError => error
70
+ raise ClosedError, error.message
71
+ end
72
+
73
+ # Shut down the reading direction while leaving the writing direction available.
74
+ def close_read
75
+ return if @read_closed
76
+
77
+ @read_closed = true
78
+ @io.shutdown(::Socket::SHUT_RD)
79
+ rescue IOError, SystemCallError
80
+ # The channel was already closed concurrently:
81
+ end
82
+
83
+ # Shut down the writing direction while leaving the reading direction available.
84
+ def close_write
85
+ @write_mutex.synchronize do
86
+ return if @write_closed
87
+
88
+ @write_closed = true
89
+ @io.shutdown(::Socket::SHUT_WR)
90
+ end
91
+ rescue IOError, SystemCallError
92
+ # The channel was already closed concurrently:
93
+ end
94
+
95
+ # Close the underlying IO object.
96
+ def close
97
+ return if @io.closed?
98
+
99
+ @read_closed = true
100
+ @write_closed = true
101
+ @io.close
102
+ rescue IOError
103
+ # The channel was already closed concurrently:
104
+ end
105
+
106
+ # @returns [Boolean] Whether the channel has been closed.
107
+ def closed?
108
+ (@read_closed && @write_closed) || @io.closed?
109
+ end
110
+
111
+ private
112
+
113
+ # Write the complete buffer to the IO object.
114
+ def write_all(buffer)
115
+ offset = 0
116
+
117
+ while offset < buffer.bytesize
118
+ offset += @io.write(buffer.byteslice(offset, buffer.bytesize - offset))
119
+ end
120
+ end
121
+
122
+ # Read exactly the requested number of bytes.
123
+ def read_exactly(length, eof: false)
124
+ buffer = String.new(capacity: length, encoding: Encoding::BINARY)
125
+
126
+ while buffer.bytesize < length
127
+ begin
128
+ buffer << @io.readpartial(length - buffer.bytesize)
129
+ rescue EOFError
130
+ if eof && buffer.empty?
131
+ return nil
132
+ end
133
+
134
+ raise
135
+ end
136
+ end
137
+
138
+ return buffer
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "protocol/http/error"
7
+
8
+ module Protocol
9
+ module HTTP
10
+ module Executor
11
+ # Raised when the remote execution context reports an error.
12
+ class RemoteError < ::Protocol::HTTP::RemoteError
13
+ # Convert an exception into a transport-safe description.
14
+ #
15
+ # @parameter error [Exception] The exception to describe.
16
+ # @returns [Hash] A transport-safe error description.
17
+ def self.dump(error)
18
+ {
19
+ class: error.class.name,
20
+ message: error.message,
21
+ backtrace: error.backtrace,
22
+ }
23
+ end
24
+
25
+ # Initialize a remote error from its description.
26
+ #
27
+ # @parameter description [Hash] The remote error description.
28
+ def initialize(description)
29
+ @description = description
30
+
31
+ super("#{description[:class]}: #{description[:message]}")
32
+ self.set_backtrace(description[:backtrace]) if description[:backtrace]
33
+ end
34
+
35
+ # @attribute [Hash] The remote error description.
36
+ attr :description
37
+ end
38
+
39
+ # Raised when an execution transport closes unexpectedly.
40
+ class ClosedError < RemoteError
41
+ # Initialize an unexpected transport closure.
42
+ def initialize(message = "The execution transport closed unexpectedly.")
43
+ super(class: self.class.name, message: message, backtrace: nil)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end