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
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.
|
data/context/index.yaml
ADDED
|
@@ -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
|