protocol-htty 0.1.0 → 0.3.0

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: c222bcf565b63477735114b057e88c45508674629d78646c0fa73ab34b1abbd9
4
- data.tar.gz: fd7405a11f04ee961c218473951ef8f0051dde6f0c581c2ecc129a87f9fbfe37
3
+ metadata.gz: 87a3aa4c350c134deb40265ae418e15fbd1158b70a8cf4c21e1748de514ca867
4
+ data.tar.gz: 506e9101a8df67e75aed817e3227919e06dc00ee5aa8d58d62c6d7139ae8d1ea
5
5
  SHA512:
6
- metadata.gz: 0cf629cde9cb0d76603be89e9290335087a540c92be10e0f433732e6f323306ec8ddd7ba5cbe104ea0b7ef9ac57983bd3b0017005742c4065af1ecceebeca34d
7
- data.tar.gz: db74e7230fdd22dbc18f1f4e85526856d3ebdd9eb550c1f7c3fdd6c3e84fcfc45d9ccb97559f813c9985d39d693e0ba2ef21d28a337086f94f76367c8de6dbfc
6
+ metadata.gz: 82d2c3b0fbbd56482711a10da4dad9e0e86177168d1cbc198d126e1d0c347e27dd3123e5d3982f9884e7bc2ee8b8f1be429164f06f16d5c3dde65ff5424750e3
7
+ data.tar.gz: cb61305b1df49d733cc5b4e0755d2d8962813eafc1e3fb2137122014643b7b7a90bfd44e2490edd428eb32b8bb9a7e31745bfeaa8b8b847ea1ad39d5c2f8aa25
checksums.yaml.gz.sig CHANGED
Binary file
@@ -7,86 +7,125 @@ require "io/stream"
7
7
 
8
8
  module Protocol
9
9
  module HTTY
10
- # Transport an opaque byte stream over HTTY chunks.
10
+ # Transport an opaque byte stream after the HTTY bootstrap handshake.
11
11
  class Stream
12
- # Since base64 encoding adds 33% overhead, we can fit 3KB of binary data into a single HTTY chunk without exceeding the typical MTU of 4KB:
13
- PACKET_SIZE = 1024*3
14
-
15
- # Create a stream on top of HTTY framed input and output.
16
- # @parameter input [IO | IO::Stream] The source of framed HTTY chunks.
17
- # @parameter output [IO | IO::Stream | Nil] The sink for framed HTTY chunks.
18
- # @parameter packet_size [Integer] The maximum payload size for each chunk.
19
- def initialize(input, output = input, packet_size: PACKET_SIZE)
20
- @framer = Framer.new(::IO::Stream(input), ::IO::Stream(output))
21
- @packet_size = packet_size
22
- @buffer = +"".b
12
+ ESC = "\e"
13
+ DCS = "#{ESC}P"
14
+ ST = "#{ESC}\\"
15
+ BOOTSTRAP_PREFIX = "+H"
16
+ RAW_MODE = "raw"
17
+
18
+ HTTP2_FRAME_HEADER_SIZE = 9
19
+
20
+ def self.open(input, output, bootstrap: nil, mode: RAW_MODE)
21
+ stream = self.new(input, output)
22
+
23
+ case bootstrap
24
+ when :write
25
+ stream.write_bootstrap(mode)
26
+ when :read
27
+ actual_mode = stream.read_bootstrap
28
+
29
+ unless actual_mode == mode
30
+ raise ProtocolError, "Expected HTTY bootstrap mode #{mode.inspect}, got #{actual_mode.inspect}"
31
+ end
32
+ end
33
+
34
+ return stream
35
+ end
36
+
37
+ # Create a stream on top of raw byte-preserving endpoints.
38
+ # @parameter input [IO] The readable endpoint.
39
+ # @parameter output [IO] The writable endpoint.
40
+ def initialize(input, output)
41
+ @input = input
42
+ @output = ::IO::Stream(output)
43
+ @frame_remaining = nil
23
44
  @local_closed = false
24
- @remote_closed = false
25
45
  end
26
46
 
27
- attr :framer
47
+ attr :input
48
+ attr :output
28
49
 
29
- # Return the writable IO object used by the underlying framer.
30
- # @returns [IO | IO::Stream] The output side of the framed transport.
50
+ # Return the underlying output stream.
31
51
  def io
32
- @framer.output
52
+ @output
53
+ end
54
+
55
+ def write_bootstrap(mode = RAW_MODE)
56
+ @output.write("#{DCS}#{BOOTSTRAP_PREFIX}#{mode}#{ST}")
57
+ @output.flush
58
+ end
59
+
60
+ def read_bootstrap
61
+ while payload = read_payload
62
+ next unless payload.start_with?(BOOTSTRAP_PREFIX)
63
+ mode = payload.delete_prefix(BOOTSTRAP_PREFIX)
64
+
65
+ unless mode == RAW_MODE
66
+ raise ProtocolError, "Unsupported HTTY bootstrap mode: #{mode.inspect}"
67
+ end
68
+
69
+ return mode
70
+ end
71
+
72
+ return nil
33
73
  end
34
74
 
35
75
  # Read application bytes from the HTTY transport.
36
- # @parameter length [Integer | Nil] The exact number of bytes to read, or `nil` for all buffered bytes.
37
- # @returns [String | Nil] The requested bytes, an empty binary string for `0`, or `nil` if more data is required or the remote side is closed.
38
76
  def read(length = nil)
39
- return +"".b if length == 0
40
-
41
- fill(length)
77
+ if length == 0
78
+ @frame_remaining = nil if @frame_remaining == 0
79
+ return +"".b
80
+ end
42
81
 
43
- return nil if @buffer.empty? && @remote_closed
44
- return nil if @buffer.empty?
45
- return nil if length && @buffer.bytesize < length && !@remote_closed
82
+ requested_length = length
83
+ length = [length, @frame_remaining].min if length && @frame_remaining && @frame_remaining > 0
84
+ buffer = read_exact(length)
46
85
 
47
- if length
48
- return @buffer.slice!(0, length)
49
- else
50
- return @buffer.slice!(0, @buffer.bytesize)
86
+ if buffer && requested_length == HTTP2_FRAME_HEADER_SIZE && !@frame_remaining
87
+ if buffer.bytesize == HTTP2_FRAME_HEADER_SIZE
88
+ frame_length = self.class.frame_length(buffer)
89
+ @frame_remaining = frame_length if frame_length > 0
90
+ end
91
+ elsif buffer && @frame_remaining
92
+ @frame_remaining -= buffer.bytesize
93
+ @frame_remaining = nil if @frame_remaining <= 0
51
94
  end
95
+
96
+ return buffer
52
97
  end
53
98
 
54
- # Write application bytes as one or more HTTY chunks.
55
- # @parameter data [String | Array(Integer)] The opaque bytes to send.
99
+ # Write application bytes after bootstrap.
56
100
  # @returns [self]
57
101
  # @raises [IOError] If the local side of the transport is closed.
58
- def write(data)
102
+ def write(data, flush: false)
59
103
  raise IOError, "HTTY stream is closed for writing!" if @local_closed
60
104
 
61
- data = data.to_s.b
62
-
63
- until data.empty?
64
- chunk = data.byteslice(0, @packet_size)
65
- @framer.write_chunk(chunk)
66
- data = data.byteslice(chunk.bytesize..)
67
- end
68
-
69
- @framer.flush
105
+ @output.write(data.to_s.b)
106
+ @output.flush if flush
70
107
 
71
108
  return self
72
109
  end
73
110
 
74
- # Flush any buffered output through the underlying framer.
111
+ # Flush any buffered output through the underlying stream.
75
112
  # @returns [void]
76
113
  def flush
77
- @framer.flush
114
+ @output.flush
78
115
  end
79
116
 
80
117
  # Close the local write side of this stream abstraction.
81
118
  # HTTY does not define a close packet, and closing this object does not close the underlying terminal IO.
82
119
  # @returns [void]
83
- def close
120
+ def close_write(error = nil)
84
121
  unless @local_closed
85
122
  @local_closed = true
86
- @framer.flush
123
+ @output.flush
87
124
  end
88
125
  end
89
126
 
127
+ alias close close_write
128
+
90
129
  # Check whether the local side of the transport is closed.
91
130
  # @returns [bool] True if local writes have been closed.
92
131
  def closed?
@@ -96,28 +135,72 @@ module Protocol
96
135
  # Check whether the remote side may still provide more data.
97
136
  # @returns [bool] True if the remote side has not sent or implied a close.
98
137
  def readable?
99
- !@remote_closed
138
+ !(@input.respond_to?(:closed?) && @input.closed?)
100
139
  end
101
140
 
102
141
  private
103
142
 
104
- def fill(length)
105
- while needs_more_data?(length)
106
- chunk = @framer.read_chunk
143
+ def self.frame_length(buffer)
144
+ length_high, length_low = buffer.unpack("Cn")
145
+ return (length_high << 16) | length_low
146
+ end
147
+
148
+ def read_exact(length)
149
+ return @input.read if length.nil?
150
+
151
+ buffer = +"".b
152
+
153
+ while buffer.bytesize < length
154
+ chunk = read_some(length - buffer.bytesize)
155
+ break unless chunk
107
156
 
108
- unless chunk
109
- @remote_closed = true
110
- break
111
- end
112
- @buffer << chunk
157
+ buffer << chunk.b
113
158
  end
159
+
160
+ return nil if buffer.empty?
161
+ return buffer
114
162
  end
115
163
 
116
- def needs_more_data?(length)
117
- return false if @remote_closed
118
- return @buffer.empty? unless length
164
+ def read_some(length)
165
+ if @input.respond_to?(:readpartial)
166
+ @input.readpartial(length)
167
+ else
168
+ @input.read(length)
169
+ end
170
+ rescue EOFError, Errno::EIO
171
+ return nil
172
+ end
173
+
174
+ def read_payload
175
+ while prefix = read_some(1)
176
+ next unless prefix == ESC
177
+
178
+ marker = read_some(1)
179
+ return nil unless marker
180
+ next unless marker == "P"
181
+
182
+ return consume_packet
183
+ end
184
+
185
+ return nil
186
+ end
187
+
188
+ def consume_packet
189
+ buffer = +""
190
+
191
+ while chunk = read_some(1)
192
+ if chunk == ESC
193
+ terminator = read_some(1)
194
+ return buffer if terminator == "\\"
195
+
196
+ buffer << chunk
197
+ buffer << terminator if terminator
198
+ else
199
+ buffer << chunk
200
+ end
201
+ end
119
202
 
120
- @buffer.bytesize < length
203
+ raise EOFError, "Incomplete HTTY chunk!"
121
204
  end
122
205
  end
123
206
  end
@@ -5,6 +5,6 @@
5
5
 
6
6
  module Protocol
7
7
  module HTTY
8
- VERSION = "0.1.0"
8
+ VERSION = "0.3.0"
9
9
  end
10
10
  end
data/lib/protocol/htty.rb CHANGED
@@ -5,5 +5,4 @@
5
5
 
6
6
  require_relative "htty/version"
7
7
  require_relative "htty/error"
8
- require_relative "htty/framer"
9
8
  require_relative "htty/stream"
data/readme.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Protocol::HTTY
2
2
 
3
- `protocol-htty` defines a small, terminal-safe framing layer for carrying an opaque byte stream over TTY side channels.
3
+ `protocol-htty` defines a small, terminal-safe bootstrap for carrying a raw HTTP/2 byte stream over terminal-attached sessions.
4
4
 
5
5
  [![Development Status](https://github.com/socketry/protocol-htty/workflows/Test/badge.svg)](https://github.com/socketry/protocol-htty/actions?workflow=Test)
6
6
 
@@ -10,34 +10,46 @@ Traditional terminal user interfaces are useful, but they are also a poor fit fo
10
10
 
11
11
  In practice, this means TUIs often force applications into compromises: text-heavy layouts, ad-hoc protocols, and bespoke escape-sequence behavior that is hard to standardise across runtimes and terminals.
12
12
 
13
- HTTY exists to keep the portability and deployment advantages of terminal workflows while avoiding the need to build an entire application model out of terminal control codes. Instead of asking the terminal stream itself to represent higher-level UI state, HTTY provides a small framing layer that can carry a normal plaintext HTTP/2 connection alongside terminal traffic, enabling applications to attach browser surfaces to a normal terminal session over HTTY.
13
+ HTTY exists to keep the portability and deployment advantages of terminal workflows while avoiding the need to build an entire application model out of terminal control codes. Instead of asking the terminal stream itself to represent higher-level UI state, HTTY provides a small bootstrap that can hand a terminal session over to a normal plaintext HTTP/2 connection, enabling applications to attach browser surfaces to a normal terminal session over HTTY.
14
14
 
15
15
  ## Design
16
16
 
17
- HTTY does not model application requests, regions, or resources. It transports the two directions of a single plaintext HTTP/2 (`h2c`) connection over terminal-safe chunks without introducing a second session protocol.
17
+ HTTY does not model application requests, regions, or resources. It transports the two directions of a single plaintext HTTP/2 (`h2c`) connection over a raw terminal-attached byte stream without introducing a second session protocol.
18
18
 
19
- Each chunk is encoded as a DCS sequence:
19
+ HTTY v1 begins with one DCS bootstrap sequence:
20
20
 
21
21
  ``` text
22
- ESC P HTTY;1;BASE64_CHUNK ESC \
22
+ ESC P + H raw ESC \
23
23
  ```
24
24
 
25
- The framing layer intentionally stays small so it can be reimplemented in other runtimes.
25
+ After that bootstrap has been consumed, the session carries plain `h2c` bytes. The takeover layer intentionally stays small so it can be reimplemented in other runtimes.
26
26
 
27
27
  ## Usage
28
28
 
29
29
  Please see the [project documentation](https://socketry.github.io/protocol-htty/) for more details.
30
30
 
31
- - [Getting Started](https://socketry.github.io/protocol-htty/guides/getting-started/index) - This guide explains how to get started with `protocol-htty` for terminal-safe HTTP/2 byte stream transport.
31
+ - [Getting Started](https://socketry.github.io/protocol-htty/guides/getting-started/index) - This guide explains how to get started with `protocol-htty` for DCS-bootstrapped raw HTTP/2 byte stream transport.
32
32
 
33
- - [HTTY Specification](https://socketry.github.io/protocol-htty/guides/specification/index) - This document specifies HTTY as a terminal-safe framing layer for carrying a plaintext HTTP/2 (`h2c`) byte stream over terminal side channels.
33
+ - [HTTY Specification](https://socketry.github.io/protocol-htty/guides/specification/index) - This document specifies HTTY as a DCS-bootstrapped raw-mode takeover transport for carrying a plaintext HTTP/2 (`h2c`) connection over terminal-attached sessions.
34
34
 
35
35
  ## Releases
36
36
 
37
37
  Please see the [project releases](https://socketry.github.io/protocol-htty/releases/index) for all releases.
38
38
 
39
+ ### v0.3.0
40
+
41
+ - Change `Protocol::HTTY::Stream` to take explicit input and output endpoints using `Stream.new(input, output)` and `Stream.open(input, output, **options)`.
42
+ - Read HTTY input without buffering ahead, preserving HTTP/2 frame boundaries by reading only the announced frame payload length.
43
+
44
+ ### v0.2.0
45
+
46
+ - Add `Protocol::HTTY::Stream.open(stream, **options)` as the preferred constructor for bootstrapping HTTY over an existing stream.
47
+ - Inline HTTY bootstrap encoding and decoding into `Protocol::HTTY::Stream`, keeping the public API focused on the single raw byte-stream abstraction used by HTTP/2.
48
+
39
49
  ### v0.1.0
40
50
 
51
+ - Initial release.
52
+
41
53
  ## Contributing
42
54
 
43
55
  We welcome contributions to this project.
data/releases.md CHANGED
@@ -1,3 +1,15 @@
1
1
  # Releases
2
2
 
3
+ ## v0.3.0
4
+
5
+ - Change `Protocol::HTTY::Stream` to take explicit input and output endpoints using `Stream.new(input, output)` and `Stream.open(input, output, **options)`.
6
+ - Read HTTY input without buffering ahead, preserving HTTP/2 frame boundaries by reading only the announced frame payload length.
7
+
8
+ ## v0.2.0
9
+
10
+ - Add `Protocol::HTTY::Stream.open(stream, **options)` as the preferred constructor for bootstrapping HTTY over an existing stream.
11
+ - Inline HTTY bootstrap encoding and decoding into `Protocol::HTTY::Stream`, keeping the public API focused on the single raw byte-stream abstraction used by HTTP/2.
12
+
3
13
  ## v0.1.0
14
+
15
+ - Initial release.
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "stringio"
7
+ require "protocol/htty"
8
+
9
+ class OneByteInput
10
+ def initialize(data)
11
+ @data = data.b
12
+ @offset = 0
13
+ end
14
+
15
+ def read(length = nil)
16
+ return nil if @offset >= @data.bytesize
17
+
18
+ length = [length || @data.bytesize, 1].min
19
+ chunk = @data.byteslice(@offset, length)
20
+ @offset += chunk.bytesize
21
+
22
+ return chunk
23
+ end
24
+ end
25
+
26
+ describe Protocol::HTTY::Stream do
27
+ let(:input) {StringIO.new}
28
+ let(:output) {StringIO.new}
29
+ let(:stream) {subject.new(input, output)}
30
+
31
+ it "writes the HTTY raw bootstrap" do
32
+ stream.write_bootstrap
33
+
34
+ expect(output.string).to be == "\eP+Hraw\e\\"
35
+ end
36
+
37
+ it "reads the HTTY raw bootstrap" do
38
+ input.string = "hello\eP+Hraw\e\\world"
39
+ input.rewind
40
+
41
+ mode = stream.read_bootstrap
42
+
43
+ expect(mode).to be == "raw"
44
+ end
45
+
46
+ it "reads a bootstrap split across one-byte reads" do
47
+ stream = subject.new(OneByteInput.new("hello\eP+Hraw\e\\"), StringIO.new)
48
+
49
+ expect(stream.read_bootstrap).to be == "raw"
50
+ end
51
+
52
+ it "preserves bytes after the HTTY bootstrap" do
53
+ input.string = "\eP+Hraw\e\\world"
54
+ input.rewind
55
+
56
+ stream.read_bootstrap
57
+
58
+ expect(stream.read(5)).to be == "world"
59
+ end
60
+
61
+ it "raises on unsupported bootstrap modes" do
62
+ input.string = "\eP+Hframed\e\\"
63
+ input.rewind
64
+
65
+ expect do
66
+ stream.read_bootstrap
67
+ end.to raise_exception(Protocol::HTTY::ProtocolError)
68
+ end
69
+
70
+ it "raises on incomplete bootstraps" do
71
+ input.string = "\eP+Hraw"
72
+ input.rewind
73
+
74
+ expect do
75
+ stream.read_bootstrap
76
+ end.to raise_exception(EOFError)
77
+ end
78
+
79
+ it "ignores unrelated DCS payloads before the bootstrap" do
80
+ input.string = "\ePfoo\e\\\eP+Hraw\e\\"
81
+ input.rewind
82
+
83
+ expect(stream.read_bootstrap).to be == "raw"
84
+ end
85
+
86
+ it "ignores implementation-specific reset markers before the bootstrap" do
87
+ input.string = "\eP+reset:token\e\\\eP+Hraw\e\\"
88
+ input.rewind
89
+
90
+ expect(stream.read_bootstrap).to be == "raw"
91
+ end
92
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "open3"
7
+ require "pty"
8
+ require "rbconfig"
9
+ require "timeout"
10
+ require "protocol/http2"
11
+ require "protocol/http2/client"
12
+ require "protocol/http2/stream"
13
+ require "protocol/htty"
14
+ require "protocol/htty/fixtures"
15
+
16
+ class PTYStream
17
+ def initialize(input, output)
18
+ @input = input
19
+ @output = output
20
+ @buffer = +"".b
21
+ end
22
+
23
+ attr :input
24
+ attr :output
25
+
26
+ def read(length)
27
+ while @buffer.bytesize < length
28
+ @buffer << @input.readpartial(4096).b
29
+ end
30
+
31
+ data = @buffer.byteslice(0, length)
32
+ @buffer = @buffer.byteslice(length, @buffer.bytesize) || +"".b
33
+
34
+ return data
35
+ rescue EOFError, Errno::EIO
36
+ raise EOFError, "PTY closed"
37
+ end
38
+
39
+ def write(data)
40
+ @output.write(data)
41
+ end
42
+
43
+ def flush
44
+ @output.flush
45
+ end
46
+
47
+ def close
48
+ @input.close rescue nil
49
+ @output.close rescue nil
50
+ end
51
+
52
+ def closed?
53
+ @input.closed?
54
+ end
55
+ end
56
+
57
+ class ResponseStream < Protocol::HTTP2::Stream
58
+ attr :response_headers
59
+ attr :body
60
+
61
+ def initialize(...)
62
+ super
63
+ @response_headers = []
64
+ @body = +"".b
65
+ end
66
+
67
+ def process_headers(frame)
68
+ @response_headers = super
69
+ end
70
+
71
+ def process_data(frame)
72
+ data = super
73
+ @body << data.b if data
74
+ return data
75
+ end
76
+ end
77
+
78
+ class Client < Protocol::HTTP2::Client
79
+ def create_stream(id = next_stream_id)
80
+ ResponseStream.create(self, id)
81
+ end
82
+ end
83
+
84
+ describe "HTTY over a real PTY" do
85
+ let(:root) {File.expand_path("../../..", __dir__)}
86
+ let(:ruby_load_path) {File.join(root, "lib")}
87
+
88
+ def spawn_fixture(name)
89
+ environment = {"RUBYLIB" => ruby_load_path}
90
+
91
+ PTY.spawn(environment, RbConfig.ruby, Protocol::HTTY::Fixtures.executable_path(name))
92
+ end
93
+
94
+ def with_fixture(name)
95
+ input, output, pid = spawn_fixture(name)
96
+ input.binmode
97
+ output.binmode
98
+
99
+ stream = PTYStream.new(input, output)
100
+
101
+ yield stream
102
+ ensure
103
+ stream&.close
104
+ Process.wait(pid) rescue nil
105
+ end
106
+
107
+ it "ignores terminal noise before the bootstrap" do
108
+ Timeout.timeout(5) do
109
+ with_fixture("bootstrap") do |stream|
110
+ framer = Protocol::HTTY::Stream.new(stream.input, stream.output)
111
+
112
+ expect(framer.read_bootstrap).to be == "raw"
113
+ expect(stream.read(3)).to be == "RAW"
114
+ end
115
+ end
116
+ end
117
+
118
+ it "delivers the HTTP/2 connection preface after raw takeover" do
119
+ Timeout.timeout(5) do
120
+ with_fixture("raw_preface") do |stream|
121
+ framer = Protocol::HTTY::Stream.new(stream.input, stream.output)
122
+
123
+ expect(framer.read_bootstrap).to be == "raw"
124
+
125
+ stream.write(Protocol::HTTP2::CONNECTION_PREFACE)
126
+ stream.flush
127
+
128
+ expect(stream.read(10)).to be == "PREFACE_OK"
129
+ end
130
+ end
131
+ end
132
+
133
+ it "runs an HTTP/2 session until command-side GOAWAY" do
134
+ Timeout.timeout(5) do
135
+ with_fixture("http2_server") do |stream|
136
+ stream = Protocol::HTTY::Stream.new(stream.input, stream.output)
137
+ stream.read_bootstrap
138
+
139
+ framer = Protocol::HTTP2::Framer.new(stream)
140
+ client = Client.new(framer)
141
+ client.send_connection_preface
142
+
143
+ request = client.create_stream
144
+ request.send_headers(
145
+ [[":method", "GET"], [":path", "/"], [":scheme", "http"], [":authority", "htty.local"]],
146
+ Protocol::HTTP2::END_STREAM
147
+ )
148
+
149
+ goaway = false
150
+
151
+ until goaway
152
+ begin
153
+ frame = client.read_frame
154
+ goaway = frame.is_a?(Protocol::HTTP2::GoawayFrame)
155
+ rescue Protocol::HTTP2::GoawayError
156
+ goaway = true
157
+ end
158
+ end
159
+
160
+ expect(request.response_headers.to_h[":status"]).to be == "200"
161
+ expect(request.body).to be == "OK\n"
162
+ end
163
+ end
164
+ end
165
+
166
+ it "treats command exit after bootstrap without GOAWAY as an abort" do
167
+ Timeout.timeout(5) do
168
+ with_fixture("abort_after_bootstrap") do |stream|
169
+ stream = Protocol::HTTY::Stream.new(stream.input, stream.output)
170
+ expect(stream.read_bootstrap).to be == "raw"
171
+
172
+ framer = Protocol::HTTP2::Framer.new(stream)
173
+
174
+ expect do
175
+ framer.read_frame
176
+ end.to raise_exception(EOFError)
177
+ end
178
+ end
179
+ end
180
+
181
+ it "recovers the surrounding shell after an aborted session" do
182
+ client = File.join(root, "examples/pty/client.rb")
183
+
184
+ output, error, status = Timeout.timeout(10) do
185
+ Open3.capture3(
186
+ {"HTTY_SHELL" => "bash", "HTTY_FAIL_ON" => "5"},
187
+ RbConfig.ruby,
188
+ client,
189
+ chdir: root
190
+ )
191
+ end
192
+
193
+ combined_output = "#{output}#{error}"
194
+
195
+ expect(status).to be(:success?)
196
+ expect(combined_output).to be(:include?, "[htty] session 5 failed")
197
+ expect(combined_output).to be(:include?, "[htty] bootstrap detected \u2013 session 6")
198
+ expect(combined_output).to be(:include?, "session 10 \u2013 status: 200")
199
+ end
200
+ end
@@ -10,41 +10,49 @@ require "protocol/htty"
10
10
 
11
11
  describe Protocol::HTTY::Stream do
12
12
  let(:writer) {StringIO.new}
13
- let(:stream) {subject.new(StringIO.new, writer, packet_size: 8)}
13
+ let(:stream) {subject.open(StringIO.new, writer)}
14
14
 
15
- it "chunks opaque payload into HTTY chunks" do
15
+ it "writes raw bytes after bootstrap" do
16
+ stream.write_bootstrap
16
17
  stream.write(Protocol::HTTP2::CONNECTION_PREFACE)
18
+ stream.flush
17
19
 
18
- expect(writer.string.scan(/\ePHTTY;1;/).size).to be > 1
20
+ expect(writer.string).to be == "\eP+Hraw\e\\#{Protocol::HTTP2::CONNECTION_PREFACE}"
19
21
  end
20
22
 
21
- it "reads back opaque bytes from HTTY chunks" do
22
- stream.write(Protocol::HTTP2::CONNECTION_PREFACE)
23
+ it "consumes the bootstrap before reading raw bytes" do
24
+ writer.write("\eP+Hraw\e\\#{Protocol::HTTP2::CONNECTION_PREFACE}")
23
25
  writer.rewind
24
-
25
- reader = subject.new(writer, StringIO.new)
26
+
27
+ reader = subject.open(writer, StringIO.new, bootstrap: :read)
26
28
 
27
29
  expect(reader.read(Protocol::HTTP2::CONNECTION_PREFACE.bytesize)).to be == Protocol::HTTP2::CONNECTION_PREFACE
28
- reader.close
29
- expect(reader.read).to be_nil
30
+ expect(reader.read).to be == ""
30
31
  end
31
32
 
32
- it "returns all buffered bytes when length is omitted" do
33
- stream.write("hello")
33
+ it "writes the bootstrap when opened in write mode" do
34
+ writer = StringIO.new
35
+ stream = subject.open(StringIO.new, writer, bootstrap: :write)
36
+
37
+ expect(writer.string).to be == "\eP+Hraw\e\\"
34
38
  stream.close
39
+ end
40
+
41
+ it "returns all bytes when length is omitted" do
42
+ writer.write("hello")
35
43
  writer.rewind
36
44
 
37
- reader = subject.new(writer, StringIO.new)
45
+ reader = subject.open(writer, StringIO.new)
38
46
 
39
47
  expect(reader.read).to be == "hello"
40
- expect(reader.read).to be_nil
48
+ expect(reader.read).to be == ""
41
49
  end
42
50
 
43
51
  it "exposes the underlying output stream" do
44
52
  expect(stream.io).to be(:is_a?, ::IO::Stream::Buffered)
45
53
  end
46
54
 
47
- it "flushes through the underlying framer" do
55
+ it "flushes through the underlying stream" do
48
56
  stream.write("hello")
49
57
 
50
58
  expect do
@@ -65,11 +73,29 @@ describe Protocol::HTTY::Stream do
65
73
 
66
74
  expect(writer).not.to be(:closed?)
67
75
  end
76
+
77
+ it "close_write closes writes but preserves readability" do
78
+ stream.close_write
79
+
80
+ expect(stream).to be(:closed?)
81
+ expect(stream).to be(:readable?)
82
+ expect(writer).not.to be(:closed?)
83
+ end
68
84
 
69
85
  it "reports when the remote side is still readable" do
70
86
  expect(stream).to be(:readable?)
71
87
  end
72
88
 
89
+ it "reads HTTP/2 frame headers and payloads without reading ahead" do
90
+ header = [0, 5, 0, 0, 1].pack("CnCCN")
91
+ input = StringIO.new("#{header}helloextra")
92
+ reader = subject.open(input, StringIO.new)
93
+
94
+ expect(reader.read(9)).to be == header
95
+ expect(reader.read(16)).to be == "hello"
96
+ expect(reader.read(5)).to be == "extra"
97
+ end
98
+
73
99
  it "rejects writes after the local side is closed" do
74
100
  stream.close
75
101
 
@@ -80,7 +106,7 @@ describe Protocol::HTTY::Stream do
80
106
 
81
107
  it "wraps raw IO handles using IO::Stream" do
82
108
  Tempfile.create("protocol-htty") do |file|
83
- io_stream = subject.new(file, file).io
109
+ io_stream = subject.open(file, file).io
84
110
 
85
111
  expect(io_stream).to be(:is_a?, ::IO::Stream::Buffered)
86
112
  io_stream.close
@@ -89,7 +115,7 @@ describe Protocol::HTTY::Stream do
89
115
 
90
116
  it "does not close wrapped raw IO handles when closed" do
91
117
  Tempfile.create("protocol-htty") do |file|
92
- wrapped_stream = subject.new(file, file)
118
+ wrapped_stream = subject.open(file, file)
93
119
 
94
120
  wrapped_stream.close
95
121
 
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: protocol-htty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -38,20 +38,6 @@ cert_chain:
38
38
  -----END CERTIFICATE-----
39
39
  date: 1980-01-02 00:00:00.000000000 Z
40
40
  dependencies:
41
- - !ruby/object:Gem::Dependency
42
- name: base64
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - ">="
46
- - !ruby/object:Gem::Version
47
- version: '0'
48
- type: :runtime
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - ">="
53
- - !ruby/object:Gem::Version
54
- version: '0'
55
41
  - !ruby/object:Gem::Dependency
56
42
  name: protocol-http2
57
43
  requirement: !ruby/object:Gem::Requirement
@@ -72,28 +58,28 @@ dependencies:
72
58
  requirements:
73
59
  - - ">="
74
60
  - !ruby/object:Gem::Version
75
- version: '0'
61
+ version: 0.13.0
76
62
  type: :runtime
77
63
  prerelease: false
78
64
  version_requirements: !ruby/object:Gem::Requirement
79
65
  requirements:
80
66
  - - ">="
81
67
  - !ruby/object:Gem::Version
82
- version: '0'
68
+ version: 0.13.0
83
69
  executables: []
84
70
  extensions: []
85
71
  extra_rdoc_files: []
86
72
  files:
87
73
  - lib/protocol/htty.rb
88
74
  - lib/protocol/htty/error.rb
89
- - lib/protocol/htty/framer.rb
90
75
  - lib/protocol/htty/stream.rb
91
76
  - lib/protocol/htty/version.rb
92
77
  - license.md
93
78
  - readme.md
94
79
  - releases.md
95
80
  - test/protocol/htty.rb
96
- - test/protocol/htty/framer.rb
81
+ - test/protocol/htty/bootstrap.rb
82
+ - test/protocol/htty/pty.rb
97
83
  - test/protocol/htty/stream.rb
98
84
  homepage: https://github.com/socketry/protocol-htty
99
85
  licenses:
metadata.gz.sig CHANGED
Binary file
@@ -1,112 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Released under the MIT License.
4
- # Copyright, 2026, by Samuel Williams.
5
-
6
- require "base64"
7
- require "protocol/http2/framer"
8
-
9
- module Protocol
10
- module HTTY
11
- # Encode and decode HTTY chunks on top of byte-oriented IO objects.
12
- class Framer
13
- ESC = "\e"
14
- DCS = "#{ESC}P"
15
- ST = "#{ESC}\\"
16
- PREFIX = "HTTY;1;"
17
-
18
- # Create a framer around the given input and output streams.
19
- # @parameter input [Interface(:read)] The stream to read framed packets from.
20
- # @parameter output [Interface(:write, :flush) | Nil] The stream to write framed packets to.
21
- def initialize(input, output = input)
22
- @input = input
23
- @output = output
24
- end
25
-
26
- attr :input
27
- attr :output
28
-
29
- # Write a single HTTY chunk to the output stream.
30
- # @parameter payload [String | Array(Integer)] The opaque bytes to encode.
31
- # @returns [void]
32
- def write_chunk(payload)
33
- encoded = Base64.strict_encode64(payload.to_s.b)
34
- @output.write("#{DCS}#{PREFIX}#{encoded}#{ST}")
35
- end
36
-
37
- # Read the next HTTY chunk from the input stream.
38
- # Non-HTTY terminal output is ignored until a valid chunk prefix is found.
39
- # @returns [String | Nil] The decoded payload, or `nil` on end of stream.
40
- # @raises [ProtocolError] If the chunk prefix or chunk structure is invalid.
41
- # @raises [ArgumentError] If the packet payload is not valid base64.
42
- # @raises [EOFError] If the chunk terminator is missing.
43
- def read_chunk
44
- while payload = read_payload
45
- if payload.start_with?("HTTY;") && !payload.start_with?(PREFIX)
46
- raise ProtocolError, "Unsupported HTTY chunk version: #{payload.inspect}"
47
- end
48
-
49
- next unless payload.start_with?(PREFIX)
50
- encoded = payload.delete_prefix(PREFIX)
51
- return Base64.strict_decode64(encoded)
52
- end
53
-
54
- return nil
55
- end
56
-
57
- # Flush the output stream if it supports flushing.
58
- # @returns [void]
59
- def flush
60
- @output.flush if @output.respond_to?(:flush)
61
- end
62
-
63
- # Close the wrapped input and output streams.
64
- # If input and output are the same object, it is only closed once.
65
- # @returns [void]
66
- def close
67
- @output.close if @output.respond_to?(:close)
68
- @input.close if !@input.equal?(@output) && @input.respond_to?(:close)
69
- end
70
-
71
- # Check whether the output stream has been closed.
72
- # @returns [bool] True if the output stream reports that it is closed.
73
- def closed?
74
- @output.respond_to?(:closed?) && @output.closed?
75
- end
76
-
77
- private
78
-
79
- def read_payload
80
- while prefix = @input.read(1)
81
- next unless prefix == ESC
82
-
83
- marker = @input.read(1)
84
- return nil unless marker
85
- next unless marker == "P"
86
-
87
- return consume_packet
88
- end
89
-
90
- return nil
91
- end
92
-
93
- def consume_packet
94
- buffer = +""
95
-
96
- while chunk = @input.read(1)
97
- if chunk == ESC
98
- terminator = @input.read(1)
99
- return buffer if terminator == "\\"
100
-
101
- buffer << chunk
102
- buffer << terminator if terminator
103
- else
104
- buffer << chunk
105
- end
106
- end
107
-
108
- raise EOFError, "Incomplete HTTY chunk!"
109
- end
110
- end
111
- end
112
- end
@@ -1,64 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Released under the MIT License.
4
- # Copyright, 2026, by Samuel Williams.
5
-
6
- require "stringio"
7
- require "protocol/http2/framer"
8
- require "protocol/htty"
9
-
10
- describe Protocol::HTTY::Framer do
11
- let(:input) {StringIO.new}
12
- let(:output) {StringIO.new}
13
- let(:framer) {subject.new(input, output)}
14
-
15
- it "writes terminal-safe chunks" do
16
- framer.write_chunk(Protocol::HTTP2::CONNECTION_PREFACE)
17
-
18
- expect(output.string).to be == "\ePHTTY;1;UFJJICogSFRUUC8yLjANCg0KU00NCg0K\e\\"
19
- end
20
-
21
- it "reads terminal-safe chunks" do
22
- input.string = "hello\ePHTTY;1;UFJJICogSFRUUC8yLjANCg0KU00NCg0K\e\\world"
23
- input.rewind
24
-
25
- chunk = framer.read_chunk
26
-
27
- expect(chunk).to be == Protocol::HTTP2::CONNECTION_PREFACE
28
- end
29
-
30
- it "raises on malformed chunks" do
31
- input.string = "\ePHTTY;1\e\\"
32
- input.rewind
33
-
34
- expect do
35
- framer.read_chunk
36
- end.to raise_exception(Protocol::HTTY::ProtocolError)
37
- end
38
-
39
- it "raises on incomplete chunks" do
40
- input.string = "\ePHTTY;1;QUJD"
41
- input.rewind
42
-
43
- expect do
44
- framer.read_chunk
45
- end.to raise_exception(EOFError)
46
- end
47
-
48
- it "closes distinct input and output streams" do
49
- framer.close
50
-
51
- expect(input).to be(:closed?)
52
- expect(output).to be(:closed?)
53
- expect(framer).to be(:closed?)
54
- end
55
-
56
- it "handles escaped bytes inside an invalid chunk payload" do
57
- input.string = "\ePHTTY;1;QUJD\eXRA==\e\\"
58
- input.rewind
59
-
60
- expect do
61
- framer.read_chunk
62
- end.to raise_exception(ArgumentError)
63
- end
64
- end