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 +7 -0
- data/context/design-overview.md +58 -0
- data/context/getting-started.md +69 -0
- data/context/index.yaml +18 -0
- data/lib/protocol/http/executor/body/input.rb +145 -0
- data/lib/protocol/http/executor/body/output.rb +177 -0
- data/lib/protocol/http/executor/channel.rb +143 -0
- data/lib/protocol/http/executor/error.rb +48 -0
- data/lib/protocol/http/executor/execution.rb +232 -0
- data/lib/protocol/http/executor/generic.rb +41 -0
- data/lib/protocol/http/executor/ractored.rb +53 -0
- data/lib/protocol/http/executor/request.rb +56 -0
- data/lib/protocol/http/executor/threaded.rb +23 -0
- data/lib/protocol/http/executor/transport.rb +52 -0
- data/lib/protocol/http/executor/version.rb +12 -0
- data/lib/protocol/http/executor/worker.rb +146 -0
- data/lib/protocol/http/executor.rb +27 -0
- data/license.md +21 -0
- data/readme.md +74 -0
- data/releases.md +9 -0
- metadata +88 -0
|
@@ -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,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.
|