async-htty 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7243a9a99e7a0957ae6a2641c13597899ab85210c5f5334aefc23c9d573681f3
4
- data.tar.gz: 5022a69037b5ec3100456de037dd263f28995a135222bde4e2a8a721f2c2e0a4
3
+ metadata.gz: f033a6514d7f18ec5f7c94b4f547ef2fa00e025eedce18d004387dde4baae5bb
4
+ data.tar.gz: 6d6a4cf76032a9e403c3f38f0ed4a46d1f6af61dd61ca3b712c68c93eabea6f8
5
5
  SHA512:
6
- metadata.gz: 8c9bbb53d42a71efe71c714cb4c6aa743c4e0f8fbefd60e4dec918a2e21d9123627a27a3994da9be17901314f684d4f3a942a6ce7caabf8092168afc9b6a2cd1
7
- data.tar.gz: b07dbf1b26da9cbd084fd7a048dc7aa7be7122f26a8190da24fb4b4bb2a2f7f472419c508f91d2797035a26e12ef516d7b693b099b7537d6e441fb4de4df44e0
6
+ metadata.gz: 1f027635ab2786cae7850b213eba3c37e3ac9762409d746a0d54e83d6a9366963d598843f7ccedbd729298c877181062dce7c28f20154c91a6a944cdea2430c3
7
+ data.tar.gz: b3bfefe4d878d7a404ffc3f3d66798f9acd5e6e17b03743849f58a281dde09a3d63c1fc85bb7918f4b8ff6a340c4335e2ddaaf1d20e882d1f270c6f953ba3f82
checksums.yaml.gz.sig CHANGED
Binary file
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "async/http/protocol/http2/server"
7
+
8
+ module Async
9
+ module HTTY
10
+ module Protocol
11
+ module HTTY
12
+ class Server < ::Async::HTTP::Protocol::HTTP2::Server
13
+ def receive_goaway(frame)
14
+ super
15
+
16
+ unless self.framer.nil?
17
+ self.send_goaway
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -6,6 +6,7 @@
6
6
  require "protocol/htty"
7
7
 
8
8
  require "async/http/protocol/http2"
9
+ require_relative "htty/server"
9
10
 
10
11
  module Async
11
12
  module HTTY
@@ -24,7 +25,7 @@ module Async
24
25
  def self.server(stream, settings: ::Async::HTTP::Protocol::HTTP2::SERVER_SETTINGS)
25
26
  stream = ::Protocol::HTTY::Stream.open(stream, bootstrap: :write)
26
27
 
27
- server = ::Async::HTTP::Protocol::HTTP2::Server.new(stream)
28
+ server = Server.new(stream)
28
29
  server.read_connection_preface(settings)
29
30
  server.start_connection
30
31
 
@@ -24,9 +24,8 @@ module Async
24
24
  block.call
25
25
  end
26
26
  end
27
-
28
27
 
29
- def self.open(app = nil, input: $stdin, output: $stdout, env: ENV, **options, &block)
28
+ def self.open(app = nil, input: $stdin, output: $stdout, error: $stderr, env: ENV, **options, &block)
30
29
  app ||= block
31
30
  server = self.new(app, **options)
32
31
 
@@ -37,13 +36,38 @@ module Async
37
36
  $stderr.puts "HTTY is not supported by this environment, visit https://htty.dev for more information."
38
37
  raise UnsupportedError, "HTTY is not supported by this environment"
39
38
  end
39
+
40
+ unless input.respond_to?(:tty?) && input.tty?
41
+ raise UnsupportedError, "HTTY requires a TTY input stream"
42
+ end
43
+
44
+ original_input = input.dup
45
+ original_output = output.dup
46
+ original_error = error.dup
47
+
48
+ stream = ::IO::Stream::Duplex(original_input, original_output)
49
+ input.reopen(File::NULL)
50
+ output.reopen(File::NULL)
51
+ error.reopen(File::NULL)
40
52
 
41
53
  Sync do |task|
42
- with_raw_terminal(input) do
43
- stream = ::IO::Stream::Duplex(input, output)
54
+ with_raw_terminal(original_input) do
44
55
  server.accept(stream, task: task)
45
56
  end
46
57
  end
58
+
59
+ ensure
60
+ if original_input
61
+ input.reopen(original_input)
62
+ end
63
+
64
+ if original_output
65
+ output.reopen(original_output)
66
+ end
67
+
68
+ if original_error
69
+ error.reopen(original_error)
70
+ end
47
71
  end
48
72
 
49
73
  def initialize(app, protocol: Protocol::HTTY)
@@ -5,6 +5,6 @@
5
5
 
6
6
  module Async
7
7
  module HTTY
8
- VERSION = "0.1.0"
8
+ VERSION = "0.2.1"
9
9
  end
10
10
  end
data/readme.md CHANGED
@@ -14,8 +14,20 @@ Please see the [project documentation](https://socketry.github.io/async-htty/) f
14
14
 
15
15
  Please see the [project releases](https://socketry.github.io/async-htty/releases/index) for all releases.
16
16
 
17
+ ### v0.2.1
18
+
19
+ - Send a server-side GOAWAY when the HTTY client closes an HTTP/2 session, allowing terminal clients to detach cleanly.
20
+ - Add PTY coverage for binary request/response bodies across the full byte range.
21
+
22
+ ### v0.2.0
23
+
24
+ - Reopen `stdin`, `stdout`, and `stderr` to null devices to prevent output from interfering with HTTY's byte stream.
25
+ - Guard against non-TTY input streams, which are not supported by HTTY.
26
+
17
27
  ### v0.1.0
18
28
 
29
+ - Initial implementation.
30
+
19
31
  ## Contributing
20
32
 
21
33
  We welcome contributions to this project.
data/releases.md CHANGED
@@ -1,3 +1,15 @@
1
1
  # Releases
2
2
 
3
+ ## v0.2.1
4
+
5
+ - Send a server-side GOAWAY when the HTTY client closes an HTTP/2 session, allowing terminal clients to detach cleanly.
6
+ - Add PTY coverage for binary request/response bodies across the full byte range.
7
+
8
+ ## v0.2.0
9
+
10
+ - Reopen `stdin`, `stdout`, and `stderr` to null devices to prevent output from interfering with HTTY's byte stream.
11
+ - Guard against non-TTY input streams, which are not supported by HTTY.
12
+
3
13
  ## v0.1.0
14
+
15
+ - Initial implementation.
@@ -4,12 +4,48 @@
4
4
  # Copyright, 2026, by Samuel Williams.
5
5
 
6
6
  require "async"
7
+ require "protocol/http/body/writable"
7
8
  require "protocol/http/request"
8
9
  require "protocol/http/response"
9
10
  require "protocol/http2"
11
+ require "protocol/http2/client"
12
+ require "protocol/http2/stream"
13
+ require "rbconfig"
10
14
  require "async/htty"
11
15
 
16
+ require "async/htty/pty_stream"
17
+
18
+ class EchoResponseStream < Protocol::HTTP2::Stream
19
+ attr :response_headers
20
+ attr :body
21
+
22
+ def initialize(...)
23
+ super
24
+ @response_headers = []
25
+ @body = +"".b
26
+ end
27
+
28
+ def process_headers(frame)
29
+ @response_headers = super
30
+ end
31
+
32
+ def process_data(frame)
33
+ data = super
34
+ @body << data.b if data
35
+ return data
36
+ end
37
+ end
38
+
39
+ class EchoClient < Protocol::HTTP2::Client
40
+ def create_stream(id = next_stream_id)
41
+ EchoResponseStream.create(self, id)
42
+ end
43
+ end
44
+
12
45
  describe Async::HTTY::Protocol::HTTY do
46
+ let(:root) {File.expand_path("../../../..", __dir__)}
47
+ let(:ruby_load_path) {File.join(root, "lib")}
48
+
13
49
  def make_pipes
14
50
  server_input, client_output = IO.pipe
15
51
  client_input, server_output = IO.pipe
@@ -36,6 +72,29 @@ describe Async::HTTY::Protocol::HTTY do
36
72
  IO::Stream::Duplex(pipes[:server_input], pipes[:server_output])
37
73
  end
38
74
 
75
+ def spawn_fixture(name)
76
+ environment = {
77
+ "HTTY" => "1",
78
+ "RUBYLIB" => [ruby_load_path, ENV["RUBYLIB"]].compact.join(":"),
79
+ }
80
+ executable = File.join(root, "fixtures", "async", "htty", "executables", name)
81
+
82
+ PTY.spawn(environment, RbConfig.ruby, executable)
83
+ end
84
+
85
+ def with_fixture(name)
86
+ input, output, pid = spawn_fixture(name)
87
+ input.binmode
88
+ output.binmode
89
+
90
+ stream = Async::HTTY::PTYStream.new(input, output)
91
+
92
+ yield stream
93
+ ensure
94
+ stream&.close
95
+ Process.wait(pid) rescue nil
96
+ end
97
+
39
98
  it "can carry an HTTP/2 request over HTTY bootstrap and raw transport" do
40
99
  pipes = make_pipes
41
100
 
@@ -60,6 +119,95 @@ describe Async::HTTY::Protocol::HTTY do
60
119
  end
61
120
  end
62
121
 
122
+ it "round trips all byte values over a real PTY" do
123
+ payload = (0x00..0xff).to_a.pack("C*")
124
+
125
+ with_fixture("echo_body.rb") do |stream|
126
+ Protocol::HTTY::Stream.new(stream).read_bootstrap
127
+
128
+ framer = Protocol::HTTP2::Framer.new(stream)
129
+ client = EchoClient.new(framer)
130
+ client.send_connection_preface
131
+
132
+ request = client.create_stream
133
+ request.send_headers(
134
+ [
135
+ [":method", "POST"],
136
+ [":path", "/echo"],
137
+ [":scheme", "http"],
138
+ [":authority", "htty.local"],
139
+ ["content-length", payload.bytesize.to_s],
140
+ ["content-type", "application/octet-stream"],
141
+ ]
142
+ )
143
+ request.send_data(payload)
144
+ request.send_data(nil)
145
+
146
+ until request.closed?
147
+ client.read_frame
148
+ end
149
+
150
+ expect(request.response_headers.to_h[":status"]).to be == "200"
151
+ expect(request.body).to be == payload
152
+ ensure
153
+ client&.send_goaway
154
+ client&.close
155
+ end
156
+ end
157
+
158
+ it "returns from accept when the client sends GOAWAY while a response body is active" do
159
+ pipes = make_pipes
160
+ body = Protocol::HTTP::Body::Writable.new
161
+
162
+ Sync do |task|
163
+ request_started = Async::Notification.new
164
+ server_finished = Async::Notification.new
165
+
166
+ server = Async::HTTY::Server.for do |request|
167
+ request_started.signal
168
+ Protocol::HTTP::Response[200, {}, body]
169
+ end
170
+
171
+ server_task = task.async do
172
+ server.accept(server_stream(pipes))
173
+ ensure
174
+ server_finished.signal
175
+ end
176
+
177
+ stream = Protocol::HTTY::Stream.open(client_stream(pipes), bootstrap: :read)
178
+ framer = Protocol::HTTP2::Framer.new(stream)
179
+ client = Protocol::HTTP2::Client.new(framer)
180
+ client.send_connection_preface
181
+
182
+ request = client.create_stream
183
+ request.send_headers(
184
+ [[":method", "GET"], [":path", "/"], [":scheme", "http"], [":authority", "htty.local"]],
185
+ Protocol::HTTP2::END_STREAM
186
+ )
187
+
188
+ request_started.wait
189
+ client.send_goaway
190
+
191
+ task.with_timeout(1) do
192
+ server_finished.wait
193
+ end
194
+
195
+ frame = task.with_timeout(1) do
196
+ loop do
197
+ frame = framer.read_frame(client.local_settings.maximum_frame_size)
198
+ break frame if frame.is_a?(Protocol::HTTP2::GoawayFrame)
199
+ end
200
+ end
201
+
202
+ expect(frame).to be(:is_a?, Protocol::HTTP2::GoawayFrame)
203
+ ensure
204
+ body.close
205
+ client&.close
206
+ server_task&.stop
207
+ close_pipes(pipes)
208
+ end
209
+ end
210
+
63
211
  it "ignores terminal noise before the HTTY bootstrap" do
64
212
  pipes = make_pipes
65
213
 
@@ -3,40 +3,24 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2026, by Samuel Williams.
5
5
 
6
- require "stringio"
7
6
  require "protocol/http/middleware"
8
7
  require "async/htty"
8
+ require "async/htty/fake_file"
9
9
 
10
10
  describe Async::HTTY::Server do
11
11
  let(:server) {subject.new(Protocol::HTTP::Middleware::Okay)}
12
12
  let(:env) {{"HTTY" => "1"}}
13
+ let(:error) {Async::HTTY::FakeFile.new}
13
14
 
14
15
  it "exposes the HTTY protocol by default" do
15
16
  expect(server.protocol).to be == Async::HTTY::Protocol::HTTY
16
17
  end
17
18
 
18
19
  it "switches tty input into raw mode while accepting a session" do
19
- input = Object.new
20
- output = StringIO.new
20
+ input = Async::HTTY::FakeFile.new(tty: true)
21
+ output = Async::HTTY::FakeFile.new
21
22
  connection = Object.new
22
23
  protocol = Object.new
23
- input.instance_variable_set(:@raw_called, false)
24
-
25
- def input.tty?
26
- true
27
- end
28
-
29
- def input.raw
30
- @raw_called = true
31
- yield
32
- end
33
-
34
- def input.raw_called?
35
- @raw_called
36
- end
37
-
38
- def input.timeout
39
- end
40
24
 
41
25
  def connection.each
42
26
  end
@@ -55,49 +39,113 @@ describe Async::HTTY::Server do
55
39
  connection
56
40
  end
57
41
 
58
- subject.open(Protocol::HTTP::Middleware::Okay, input: input, output: output, env: env, protocol: protocol)
42
+ subject.open(Protocol::HTTP::Middleware::Okay, input: input, output: output, error: error, env: env, protocol: protocol)
59
43
 
60
- expect(input.raw_called?).to be == true
44
+ expect(input).to be(:raw_called?)
45
+ expect(input).not.to be(:raw?)
61
46
  expect(output.string).to be == ""
62
47
  end
63
48
 
64
49
  it "leaves raw mode if protocol setup fails" do
65
- input = Object.new
66
- output = StringIO.new
50
+ input = Async::HTTY::FakeFile.new(tty: true)
51
+ output = Async::HTTY::FakeFile.new
67
52
  protocol = Object.new
68
- input.instance_variable_set(:@raw_exited, false)
69
53
 
70
- def input.tty?
71
- true
54
+ protocol.define_singleton_method(:server) do |_stream|
55
+ raise EOFError, "aborted"
72
56
  end
73
57
 
74
- def input.raw
75
- yield
76
- ensure
77
- @raw_exited = true
58
+ expect do
59
+ subject.open(Protocol::HTTP::Middleware::Okay, input: input, output: output, error: error, env: env, protocol: protocol)
60
+ end.to raise_exception(EOFError, message: be =~ /aborted/)
61
+
62
+ expect(input).to be(:raw_exited?)
63
+ expect(input).not.to be(:raw?)
64
+ end
65
+
66
+ it "reopens stdio streams while accepting and restores them afterwards" do
67
+ input = Async::HTTY::FakeFile.new("request", tty: true)
68
+ output = Async::HTTY::FakeFile.new
69
+ error = Async::HTTY::FakeFile.new("diagnostics")
70
+ connection = Object.new
71
+ protocol = Object.new
72
+ reopened_to_null = nil
73
+ duplex_input = nil
74
+ duplex_output = nil
75
+
76
+ def connection.each
78
77
  end
79
78
 
80
- def input.raw_exited?
81
- @raw_exited
79
+ def connection.closed?
80
+ false
82
81
  end
83
82
 
84
- def input.timeout
83
+ def connection.send_goaway
84
+ end
85
+
86
+ def connection.close
85
87
  end
86
88
 
87
89
  protocol.define_singleton_method(:server) do |stream|
90
+ reopened_to_null = [input.reopened_to_null?, output.reopened_to_null?, error.reopened_to_null?]
91
+ duplex_input = stream.io.input
92
+ duplex_output = stream.io.output
93
+
94
+ stream.write("response", flush: true)
95
+
96
+ connection
97
+ end
98
+
99
+ subject.open(Protocol::HTTP::Middleware::Okay, input: input, output: output, error: error, env: env, protocol: protocol)
100
+
101
+ expect(reopened_to_null).to be == [true, true, true]
102
+ expect(duplex_input).not.to be == input
103
+ expect(duplex_output).not.to be == output
104
+ expect(duplex_input.string).to be == "request"
105
+
106
+ expect(input).not.to be(:reopened_to_null?)
107
+ expect(output).not.to be(:reopened_to_null?)
108
+ expect(error).not.to be(:reopened_to_null?)
109
+
110
+ expect(input.reopen_events).to be == [:null, :file]
111
+ expect(output.reopen_events).to be == [:null, :file]
112
+ expect(error.reopen_events).to be == [:null, :file]
113
+
114
+ expect(input.string).to be == "request"
115
+ expect(output.string).to be == "response"
116
+ expect(error.string).to be == "diagnostics"
117
+ end
118
+
119
+ it "restores stdio streams if protocol setup fails" do
120
+ input = Async::HTTY::FakeFile.new("request", tty: true)
121
+ output = Async::HTTY::FakeFile.new("response")
122
+ error = Async::HTTY::FakeFile.new("diagnostics")
123
+ protocol = Object.new
124
+
125
+ protocol.define_singleton_method(:server) do |_stream|
88
126
  raise EOFError, "aborted"
89
127
  end
90
128
 
91
129
  expect do
92
- subject.open(Protocol::HTTP::Middleware::Okay, input: input, output: output, env: env, protocol: protocol)
130
+ subject.open(Protocol::HTTP::Middleware::Okay, input: input, output: output, error: error, env: env, protocol: protocol)
93
131
  end.to raise_exception(EOFError, message: be =~ /aborted/)
94
132
 
95
- expect(input.raw_exited?).to be == true
133
+ expect(input).not.to be(:reopened_to_null?)
134
+ expect(output).not.to be(:reopened_to_null?)
135
+ expect(error).not.to be(:reopened_to_null?)
136
+
137
+ expect(input.reopen_events).to be == [:null, :file]
138
+ expect(output.reopen_events).to be == [:null, :file]
139
+ expect(error.reopen_events).to be == [:null, :file]
140
+
141
+ expect(input.string).to be == "request"
142
+ expect(output.string).to be == "response"
143
+ expect(error.string).to be == "diagnostics"
96
144
  end
97
145
 
98
146
  it "sends command-side GOAWAY before closing the connection" do
99
- input = StringIO.new
100
- output = StringIO.new
147
+ input = Async::HTTY::FakeFile.new(tty: true)
148
+ output = Async::HTTY::FakeFile.new
101
149
  connection = Object.new
102
150
  protocol = Object.new
103
151
  events = []
@@ -121,14 +169,14 @@ describe Async::HTTY::Server do
121
169
  connection
122
170
  end
123
171
 
124
- subject.open(Protocol::HTTP::Middleware::Okay, input: input, output: output, env: env, protocol: protocol)
172
+ subject.open(Protocol::HTTP::Middleware::Okay, input: input, output: output, error: error, env: env, protocol: protocol)
125
173
 
126
174
  expect(events).to be == [:goaway, :close]
127
175
  end
128
176
 
129
177
  it "opens a server with default stdio-style arguments" do
130
- input = StringIO.new
131
- output = StringIO.new
178
+ input = Async::HTTY::FakeFile.new(tty: true)
179
+ output = Async::HTTY::FakeFile.new
132
180
  accepted = false
133
181
 
134
182
  server = Object.new
@@ -148,7 +196,7 @@ describe Async::HTTY::Server do
148
196
  server
149
197
  end
150
198
 
151
- subject.open(Protocol::HTTP::Middleware::Okay, input: input, output: output, env: env, protocol: protocol)
199
+ subject.open(Protocol::HTTP::Middleware::Okay, input: input, output: output, error: error, env: env, protocol: protocol)
152
200
 
153
201
  expect(accepted).to be == true
154
202
  expect(output.string).to be == ""
@@ -156,11 +204,23 @@ describe Async::HTTY::Server do
156
204
 
157
205
  it "raises a typed error when HTTY is disabled" do
158
206
  expect do
159
- subject.open(Protocol::HTTP::Middleware::Okay, input: StringIO.new, output: StringIO.new, env: {"HTTY" => "0"})
207
+ subject.open(Protocol::HTTP::Middleware::Okay, input: Async::HTTY::FakeFile.new, output: Async::HTTY::FakeFile.new, error: error, env: {"HTTY" => "0"})
160
208
  end.to raise_exception(Async::HTTY::DisabledError, message: be =~ /disabled/)
161
209
 
162
210
  expect(Async::HTTY::DisabledError).to be < Async::HTTY::UnsupportedError
163
211
  end
212
+
213
+ it "raises a typed error when stdin is not a tty" do
214
+ input = Async::HTTY::FakeFile.new
215
+ output = Async::HTTY::FakeFile.new
216
+
217
+ expect do
218
+ subject.open(Protocol::HTTP::Middleware::Okay, input: input, output: output, error: error, env: env)
219
+ end.to raise_exception(Async::HTTY::UnsupportedError, message: be =~ /TTY input/)
220
+
221
+ expect(input.reopen_events).to be == []
222
+ expect(output.reopen_events).to be == []
223
+ end
164
224
 
165
225
  it "prints help and raises a typed error when HTTY is not advertised" do
166
226
  error_output = StringIO.new
@@ -170,7 +230,7 @@ describe Async::HTTY::Server do
170
230
  $stderr = error_output
171
231
 
172
232
  expect do
173
- subject.open(Protocol::HTTP::Middleware::Okay, input: StringIO.new, output: StringIO.new, env: {})
233
+ subject.open(Protocol::HTTP::Middleware::Okay, input: Async::HTTY::FakeFile.new, output: Async::HTTY::FakeFile.new, env: {})
174
234
  end.to raise_exception(Async::HTTY::UnsupportedError, message: be =~ /not supported/)
175
235
  ensure
176
236
  $stderr = original_stderr
@@ -180,8 +240,8 @@ describe Async::HTTY::Server do
180
240
  end
181
241
 
182
242
  it "opens a server within its own async context when no task is provided" do
183
- input = StringIO.new
184
- output = StringIO.new
243
+ input = Async::HTTY::FakeFile.new(tty: true)
244
+ output = Async::HTTY::FakeFile.new
185
245
  accepted = false
186
246
 
187
247
  server = Object.new
@@ -201,7 +261,7 @@ describe Async::HTTY::Server do
201
261
  server
202
262
  end
203
263
 
204
- subject.open(Protocol::HTTP::Middleware::Okay, input: input, output: output, env: env, protocol: protocol)
264
+ subject.open(Protocol::HTTP::Middleware::Okay, input: input, output: output, error: error, env: env, protocol: protocol)
205
265
 
206
266
  expect(accepted).to be == true
207
267
  end
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-htty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -105,6 +105,7 @@ files:
105
105
  - lib/async/htty/error.rb
106
106
  - lib/async/htty/protocol.rb
107
107
  - lib/async/htty/protocol/htty.rb
108
+ - lib/async/htty/protocol/htty/server.rb
108
109
  - lib/async/htty/server.rb
109
110
  - lib/async/htty/version.rb
110
111
  - license.md
metadata.gz.sig CHANGED
Binary file