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.
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "async/task"
7
+ require "protocol/http/response"
8
+
9
+ module Protocol
10
+ module HTTP
11
+ module Executor
12
+ # Manages one request and its isolated worker transport.
13
+ class Execution
14
+ # Initialize an execution.
15
+ #
16
+ # @parameter endpoint [Transport::Endpoint] The caller endpoint.
17
+ # @parameter backend [Thread | Ractor] The isolated execution context.
18
+ # @parameter request [Protocol::HTTP::Request] The original request.
19
+ # @parameter parent [Async::Task] The parent task for body forwarding.
20
+ def initialize(endpoint, backend, request, parent)
21
+ @endpoint = endpoint
22
+ @backend = backend
23
+ @request = request
24
+ @parent = parent
25
+ @input_task = nil
26
+ @input_close_task = nil
27
+ @finished = false
28
+ @mutex = Mutex.new
29
+ end
30
+
31
+ # @attribute [Channel] The control channel.
32
+ def control
33
+ @endpoint.control
34
+ end
35
+
36
+ # @attribute [Channel] The bidirectional body channel.
37
+ def body
38
+ @endpoint.body
39
+ end
40
+
41
+ # Start the worker request and wait for the response head.
42
+ #
43
+ # @returns [Protocol::HTTP::Response] The reconstructed response.
44
+ def call
45
+ description = request_description(@request)
46
+ control.write(:request, description)
47
+
48
+ if description[:body]
49
+ @input_task = @parent.async do
50
+ forward_request_body(@request.body)
51
+ end
52
+ end
53
+
54
+ wait_for_response
55
+ rescue
56
+ cancel($!)
57
+ raise
58
+ end
59
+
60
+ # Forward upgraded-stream input after the request body phase.
61
+ #
62
+ # @parameter stream [IO | Object] The caller's duplex stream.
63
+ # @returns [Async::Task] The forwarding task.
64
+ def stream_input(stream)
65
+ @parent.async do
66
+ @input_task&.wait
67
+
68
+ while chunk = read_stream_chunk(stream)
69
+ body.write(:stream_chunk, chunk)
70
+ end
71
+ rescue => error
72
+ begin
73
+ body.write(:stream_error, RemoteError.dump(error))
74
+ rescue ClosedError
75
+ # The worker has already finished:
76
+ end
77
+ ensure
78
+ body.close_write
79
+ end
80
+ end
81
+
82
+ # Finish the request direction once the initial request body has been forwarded.
83
+ def close_input
84
+ @input_close_task ||= @parent.async do
85
+ @input_task&.wait
86
+ body.close_write
87
+ end
88
+ end
89
+
90
+ # Finish the execution and release its transport.
91
+ def finish
92
+ return unless transition_to_finished
93
+
94
+ @input_task&.cancel
95
+ @input_close_task&.cancel
96
+ @endpoint.close
97
+ @backend.join
98
+ return nil
99
+ end
100
+
101
+ # Cancel the execution and release its transport.
102
+ #
103
+ # @parameter error [Exception | Nil] The cancellation reason.
104
+ def cancel(error = nil)
105
+ return unless transition_to_finished
106
+
107
+ begin
108
+ control.write(:cancel, error && RemoteError.dump(error))
109
+ rescue ClosedError
110
+ # The worker has already finished:
111
+ end
112
+
113
+ @input_task&.cancel
114
+ @input_close_task&.cancel
115
+ @request.close(error)
116
+ @endpoint.close
117
+ return nil
118
+ end
119
+
120
+ private
121
+
122
+ def wait_for_response
123
+ loop do
124
+ message = control.read
125
+ raise ClosedError unless message
126
+
127
+ type, payload = message
128
+ case type
129
+ when :interim_response
130
+ status, fields = payload
131
+ @request.send_interim_response(status, ::Protocol::HTTP::Headers[fields])
132
+ when :response
133
+ return build_response(payload)
134
+ when :error
135
+ raise RemoteError.new(payload)
136
+ else
137
+ raise ClosedError, "Unexpected control event: #{type.inspect}!"
138
+ end
139
+ end
140
+ end
141
+
142
+ def build_response(description)
143
+ headers = ::Protocol::HTTP::Headers[description[:headers]]
144
+ apply_trailers(headers, description[:trailers])
145
+
146
+ body = if metadata = description[:body]
147
+ Body::Output.new(self, headers, metadata)
148
+ end
149
+
150
+ response = ::Protocol::HTTP::Response.new(
151
+ description[:version],
152
+ description[:status],
153
+ headers,
154
+ body,
155
+ description[:protocol],
156
+ )
157
+
158
+ finish unless body
159
+ return response
160
+ end
161
+
162
+ def request_description(request)
163
+ body = request.body
164
+ body_metadata = Worker.body_metadata(body)
165
+ trailers = nil
166
+
167
+ unless body_metadata
168
+ trailers = request.headers.trailer.to_a
169
+ trailers = nil if trailers.empty?
170
+ end
171
+
172
+ return {
173
+ scheme: request.scheme,
174
+ authority: request.authority,
175
+ method: request.method,
176
+ path: request.path,
177
+ version: request.version,
178
+ headers: request.headers.header.to_a,
179
+ trailers: trailers,
180
+ body: body_metadata,
181
+ protocol: request.protocol,
182
+ peer: request.peer&.address,
183
+ }
184
+ end
185
+
186
+ def forward_request_body(body)
187
+ while chunk = body.read
188
+ self.body.write(:chunk, chunk)
189
+ end
190
+
191
+ trailers = @request.headers.trailer.to_a
192
+ self.body.write(:trailers, trailers) unless trailers.empty?
193
+ self.body.write(:end)
194
+ rescue => error
195
+ begin
196
+ self.body.write(:error, RemoteError.dump(error))
197
+ rescue ClosedError
198
+ # The worker has stopped reading the request body:
199
+ end
200
+ ensure
201
+ body.close(error)
202
+ end
203
+
204
+ def read_stream_chunk(stream)
205
+ if stream.respond_to?(:read_partial)
206
+ return stream.read_partial
207
+ end
208
+
209
+ return stream.readpartial(64 * 1024)
210
+ rescue EOFError
211
+ return nil
212
+ end
213
+
214
+ def apply_trailers(headers, trailers)
215
+ return unless trailers
216
+
217
+ headers.trailer!
218
+ trailers.each{|key, value| headers.add(key, value, trailer: true)}
219
+ end
220
+
221
+ def transition_to_finished
222
+ @mutex.synchronize do
223
+ return false if @finished
224
+
225
+ @finished = true
226
+ return true
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "async/task"
7
+ require "protocol/http/middleware"
8
+
9
+ module Protocol
10
+ module HTTP
11
+ module Executor
12
+ # The common middleware implementation for isolated execution contexts.
13
+ class Generic < ::Protocol::HTTP::Middleware
14
+ # Execute a request using a new isolated execution context.
15
+ #
16
+ # @parameter request [Protocol::HTTP::Request] The request to execute.
17
+ # @returns [Protocol::HTTP::Response] The remote response.
18
+ def call(request)
19
+ parent = ::Async::Task.current
20
+ endpoint, worker_streams = Transport.pair
21
+ backend = spawn(worker_streams)
22
+
23
+ Execution.new(endpoint, backend, request, parent).call
24
+ rescue
25
+ endpoint&.close
26
+ raise
27
+ end
28
+
29
+ private
30
+
31
+ # Spawn the isolated execution context.
32
+ #
33
+ # @parameter worker_streams [Array(IO)] The worker transport streams.
34
+ # @returns [Thread | Ractor] The isolated execution context.
35
+ def spawn(worker_streams)
36
+ raise NotImplementedError
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Protocol
7
+ module HTTP
8
+ module Executor
9
+ # Executes each request in a dedicated Ruby 4.1 Ractor.
10
+ class Ractored < Generic
11
+ # Initialize a Ractored executor.
12
+ #
13
+ # @parameter delegate [Interface(:call)] A shareable HTTP application.
14
+ def initialize(delegate)
15
+ unless self.class.supported?
16
+ raise NotImplementedError, "Ractor execution requires the Ruby head/4.1 Ractor API."
17
+ end
18
+
19
+ unless ::Ractor.shareable?(delegate)
20
+ raise ArgumentError, "The Ractor application must be shareable."
21
+ end
22
+
23
+ # Protocol::HTTP applications construct headers inside the worker, so the
24
+ # default policy must be visible from non-main Ractors:
25
+ ::Ractor.make_shareable(::Protocol::HTTP::Headers::POLICY)
26
+
27
+ super
28
+ end
29
+
30
+ # @returns [Boolean] Whether the required Ractor API is available.
31
+ def self.supported?
32
+ defined?(::Ractor) && ::Ractor.method_defined?(:join) && ::Ractor.method_defined?(:value)
33
+ end
34
+
35
+ private
36
+
37
+ def spawn(worker_streams)
38
+ descriptors = worker_streams.map(&:fileno).freeze
39
+ worker = ::Ractor.new(@delegate, descriptors, name: "protocol-http-executor"){|application, descriptors| Protocol::HTTP::Executor::Worker.run(application, descriptors.map{|descriptor| ::Socket.for_fd(descriptor)})}
40
+
41
+ # The worker now owns the descriptors. Close these wrappers without closing
42
+ # the underlying descriptors:
43
+ worker_streams.each do |stream|
44
+ stream.autoclose = false
45
+ stream.close
46
+ end
47
+
48
+ return worker
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "protocol/http/request"
7
+ require "protocol/http/peer"
8
+
9
+ module Protocol
10
+ module HTTP
11
+ module Executor
12
+ # A reconstructed request owned by the execution context.
13
+ class Request < ::Protocol::HTTP::Request
14
+ # Initialize a reconstructed request.
15
+ #
16
+ # @parameter description [Hash] The serialized request description.
17
+ # @parameter endpoint [Transport::Endpoint] The worker transport endpoint.
18
+ def initialize(description, endpoint)
19
+ @peer = if address = description[:peer]
20
+ ::Protocol::HTTP::Peer.new(address)
21
+ end
22
+
23
+ headers = ::Protocol::HTTP::Headers[description[:headers]]
24
+ if trailers = description[:trailers]
25
+ headers.trailer!
26
+ trailers.each{|key, value| headers.add(key, value, trailer: true)}
27
+ end
28
+
29
+ body = if metadata = description[:body]
30
+ Body::Input.new(endpoint.body, headers, metadata)
31
+ end
32
+
33
+ interim_response = proc do |status, interim_headers|
34
+ fields = ::Protocol::HTTP::Headers[interim_headers].header.to_a
35
+ endpoint.control.write(:interim_response, [status, fields])
36
+ end
37
+
38
+ super(
39
+ description[:scheme],
40
+ description[:authority],
41
+ description[:method],
42
+ description[:path],
43
+ description[:version],
44
+ headers,
45
+ body,
46
+ description[:protocol],
47
+ interim_response,
48
+ )
49
+ end
50
+
51
+ # @attribute [Protocol::HTTP::Peer | Nil] The reconstructed remote peer.
52
+ attr :peer
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Protocol
7
+ module HTTP
8
+ module Executor
9
+ # Executes each request in a dedicated native thread.
10
+ class Threaded < Generic
11
+ private
12
+
13
+ def spawn(worker_streams)
14
+ application = @delegate
15
+
16
+ return Thread.new(application, worker_streams) do |delegate, streams|
17
+ Worker.run(delegate, streams)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,52 @@
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 per-request transport with independent control and full-duplex body channels.
12
+ module Transport
13
+ # One endpoint of an execution transport.
14
+ class Endpoint
15
+ # Initialize an endpoint from its connected IO objects.
16
+ #
17
+ # @parameter control [IO] The bidirectional control stream.
18
+ # @parameter body [IO] The bidirectional request and response body stream.
19
+ def initialize(control, body)
20
+ @control = Channel.new(control)
21
+ @body = Channel.new(body)
22
+ end
23
+
24
+ # @attribute [Channel] The bidirectional control channel.
25
+ attr :control
26
+
27
+ # @attribute [Channel] The bidirectional body channel.
28
+ attr :body
29
+
30
+ # Close all channels.
31
+ def close
32
+ @control.close
33
+ @body.close
34
+ end
35
+ end
36
+
37
+ # Create connected client and worker transport endpoints.
38
+ #
39
+ # @returns [Array(Endpoint, Array(IO))] The client endpoint and worker IO objects.
40
+ def self.pair
41
+ client_control, worker_control = Socket.pair(:UNIX, :STREAM, 0)
42
+ client_body, worker_body = Socket.pair(:UNIX, :STREAM, 0)
43
+
44
+ client = Endpoint.new(client_control, client_body)
45
+ worker = [worker_control, worker_body]
46
+
47
+ return client, worker
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Protocol
7
+ module HTTP
8
+ module Executor
9
+ VERSION = "0.0.1"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "async"
7
+
8
+ require "protocol/http/body/stream"
9
+ require "protocol/http/response"
10
+
11
+ module Protocol
12
+ module HTTP
13
+ module Executor
14
+ # Executes one transported request in the isolated execution context.
15
+ module Worker
16
+ # Run the application using the given transport streams.
17
+ #
18
+ # @parameter application [Interface(:call)] The HTTP application.
19
+ # @parameter streams [Array(IO)] The control and bidirectional body streams.
20
+ def self.run(application, streams)
21
+ endpoint = Transport::Endpoint.new(*streams)
22
+
23
+ Sync do
24
+ execute(application, endpoint)
25
+ end
26
+ ensure
27
+ endpoint&.close
28
+ end
29
+
30
+ # Execute a request received on the endpoint.
31
+ #
32
+ # @parameter application [Interface(:call)] The HTTP application.
33
+ # @parameter endpoint [Transport::Endpoint] The worker endpoint.
34
+ def self.execute(application, endpoint)
35
+ response_started = false
36
+ message = endpoint.control.read
37
+ raise ClosedError unless message
38
+
39
+ type, description = message
40
+ raise ClosedError, "Expected a request, but received #{type.inspect}!" unless type == :request
41
+
42
+ request = Request.new(description, endpoint)
43
+ response = application.call(request)
44
+ response ||= ::Protocol::HTTP::Response[500]
45
+
46
+ body = response.body
47
+ body_metadata = body_metadata(body)
48
+ trailers = nil
49
+
50
+ unless body_metadata
51
+ trailers = response.headers.trailer.to_a
52
+ trailers = nil if trailers.empty?
53
+ end
54
+
55
+ endpoint.control.write(:response, {
56
+ version: response.version,
57
+ status: response.status,
58
+ headers: response.headers.header.to_a,
59
+ trailers: trailers,
60
+ body: body_metadata,
61
+ protocol: response.protocol,
62
+ })
63
+ response_started = true
64
+
65
+ if body_metadata
66
+ consume(response, request, endpoint)
67
+ else
68
+ response.close
69
+ end
70
+ rescue => error
71
+ if response_started
72
+ write_error(endpoint.body, error)
73
+ else
74
+ write_error(endpoint.control, error)
75
+ end
76
+
77
+ begin
78
+ response&.close(error)
79
+ rescue
80
+ # The original error has already been reported:
81
+ end
82
+ ensure
83
+ request&.close
84
+ end
85
+
86
+ # Consume the response body according to the caller's selected mode.
87
+ #
88
+ # @parameter response [Protocol::HTTP::Response] The response to consume.
89
+ # @parameter request [Request] The reconstructed request.
90
+ # @parameter endpoint [Transport::Endpoint] The worker endpoint.
91
+ def self.consume(response, request, endpoint)
92
+ message = endpoint.control.read
93
+ raise ClosedError unless message
94
+
95
+ type, mode = message
96
+ return if type == :cancel
97
+ raise ClosedError, "Expected a consumption mode, but received #{type.inspect}!" unless type == :consume
98
+
99
+ case mode
100
+ when :read
101
+ response.body.each{|chunk| endpoint.body.write(:chunk, chunk)}
102
+ when :stream
103
+ input = Body::StreamInput.new(endpoint.body, request.headers)
104
+ output = Body::StreamOutput.new(endpoint.body)
105
+ stream = ::Protocol::HTTP::Body::Stream.new(input, output)
106
+ response.body.call(stream)
107
+ else
108
+ raise ArgumentError, "Unknown response consumption mode: #{mode.inspect}!"
109
+ end
110
+
111
+ trailers = response.headers.trailer.to_a
112
+ endpoint.body.write(:trailers, trailers) unless trailers.empty?
113
+ endpoint.body.close_write
114
+ rescue => error
115
+ write_error(endpoint.body, error)
116
+ ensure
117
+ response.close(error)
118
+ end
119
+
120
+ # Describe a non-empty body for transport.
121
+ #
122
+ # @parameter body [Protocol::HTTP::Body::Readable | Nil] The body.
123
+ # @returns [Hash | Nil] The body metadata, or `nil` for no body.
124
+ def self.body_metadata(body)
125
+ return unless body
126
+ return if body.empty?
127
+
128
+ return {
129
+ length: body.length,
130
+ stream: body.stream?,
131
+ }
132
+ end
133
+
134
+ # Write an error unless the transport has already closed.
135
+ #
136
+ # @parameter channel [Channel] The destination channel.
137
+ # @parameter error [Exception] The error to report.
138
+ def self.write_error(channel, error)
139
+ channel.write(:error, RemoteError.dump(error))
140
+ rescue ClosedError
141
+ # The caller has already abandoned the execution:
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "executor/version"
7
+ require_relative "executor/error"
8
+ require_relative "executor/channel"
9
+ require_relative "executor/transport"
10
+ require_relative "executor/request"
11
+ require_relative "executor/body/input"
12
+ require_relative "executor/body/output"
13
+ require_relative "executor/worker"
14
+ require_relative "executor/execution"
15
+ require_relative "executor/generic"
16
+ require_relative "executor/threaded"
17
+ require_relative "executor/ractored"
18
+
19
+ # @namespace
20
+ module Protocol
21
+ # @namespace
22
+ module HTTP
23
+ # Execute HTTP applications in isolated execution contexts.
24
+ module Executor
25
+ end
26
+ end
27
+ end
data/license.md ADDED
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright, 2026, by Samuel Williams.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.