terminalwire 0.3.5.alpha2 → 2.0.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.
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terminalwire::V2
4
+ module Server
5
+ # Drives a Server::Connection over a transport. A background **read pump**
6
+ # continuously drains incoming frames and routes them: responses go to the
7
+ # caller blocked in #request, and unsolicited control frames (resize, and
8
+ # later interrupt) update state / fire callbacks. This is what lets the
9
+ # server always know the client's terminal size, not just while it happens to
10
+ # be inside a request.
11
+ #
12
+ # Threading: the pump runs on its own thread; the CLI runs on the caller's
13
+ # thread and blocks in #request on a per-stream queue the pump fulfills.
14
+ class Runtime
15
+ # Largest payload in a single output data frame; actual frame size is
16
+ # min(this, available flow credit).
17
+ MAX_FRAME = 32 * 1024
18
+
19
+ attr_reader :connection, :program, :entitlement, :terminal
20
+
21
+ def initialize(transport:,
22
+ server_min: Protocol::MIN_VERSION,
23
+ server_max: Protocol::MAX_VERSION,
24
+ server_capabilities: Protocol::CAPABILITIES)
25
+ @transport = transport
26
+ @connection = Connection.new(
27
+ server_min: server_min, server_max: server_max,
28
+ server_capabilities: server_capabilities
29
+ )
30
+ @terminal = Terminal.new
31
+ @flow = FlowController.new
32
+ @client_window = Protocol::DEFAULT_WINDOW
33
+ @waiters = {}
34
+ @raw_inputs = {}
35
+ @lock = Mutex.new
36
+ @ready = Queue.new
37
+ @signaled = false
38
+ @on_resize = nil
39
+ @interrupted = false
40
+ end
41
+
42
+ # Register a callback fired (on the pump thread) whenever the client's
43
+ # window resizes. The Terminal is already updated before it runs.
44
+ def on_resize(&block)
45
+ @on_resize = block
46
+ end
47
+
48
+ # Start the read pump and block until the handshake reaches ready (or fails).
49
+ # The calling thread is the CLI thread; an interrupt signal is raised into it.
50
+ def handshake
51
+ @cli_thread = Thread.current
52
+ @pump = Thread.new { pump }
53
+ result = @ready.pop
54
+ raise result if result.is_a?(Exception)
55
+
56
+ self
57
+ end
58
+
59
+ # Fire-and-forget: write a single control frame (welcome, exit, request,
60
+ # open/close). NOT flow-controlled — these are small control-plane frames.
61
+ def emit(frame)
62
+ @transport.write(Codec.encode(frame))
63
+ nil
64
+ end
65
+
66
+ # Open an output stream (:stdout/:stderr) and start its flow window at the
67
+ # client's advertised offer. Returns the stream id.
68
+ def open_output(stream)
69
+ sid, frame = @connection.open_stream(stream)
70
+ @flow.open(sid, @client_window)
71
+ emit(frame)
72
+ sid
73
+ end
74
+
75
+ # Write output to a stream, flow-controlled: each frame is sized to the
76
+ # currently available credit (blocking when the window is empty), so the
77
+ # server can never outrun the client. Raises if the connection dies.
78
+ def write_data(sid, bytes)
79
+ bytes = bytes.b
80
+ total = bytes.bytesize
81
+ if total.zero?
82
+ emit(Frames.data(sid: sid, bytes: "".b))
83
+ return
84
+ end
85
+
86
+ offset = 0
87
+ while offset < total
88
+ take = @flow.reserve(sid, [total - offset, MAX_FRAME].min)
89
+ emit(Frames.data(sid: sid, bytes: bytes.byteslice(offset, take)))
90
+ offset += take
91
+ end
92
+ end
93
+
94
+ def close_output(sid)
95
+ emit(@connection.close_stream(sid))
96
+ @flow.close(sid)
97
+ end
98
+
99
+ # Open a raw input stream: the client puts its terminal in `mode` (raw or
100
+ # cbreak) and streams keystrokes as data frames until we close it, restoring
101
+ # the prior mode on close. Returns the stream id.
102
+ def open_raw_input(mode: Protocol::Mode::RAW)
103
+ sid, frame = @connection.open_stream(Protocol::Stream::STDIN_RAW, mode: mode)
104
+ @lock.synchronize { @raw_inputs[sid] = Queue.new }
105
+ emit(frame)
106
+ sid
107
+ end
108
+
109
+ # Read the next keystroke chunk from a raw input stream; blocks until input
110
+ # arrives, returns nil when the stream is closed or the connection dies.
111
+ def read_raw(sid)
112
+ queue = @lock.synchronize { @raw_inputs[sid] }
113
+ return nil unless queue
114
+
115
+ value = queue.pop
116
+ # An interrupt and the connection's :closed both unblock this pop and can
117
+ # race; the interrupt is the user's intent, so it wins (-> exit 130). This
118
+ # makes the outcome deterministic regardless of which arrives first (the
119
+ # async/Falcon bridge could let :closed land before Thread#raise lands).
120
+ raise Interrupted if interrupted?
121
+
122
+ value == :closed ? nil : value
123
+ end
124
+
125
+ def close_raw_input(sid)
126
+ emit(@connection.close_stream(sid))
127
+ queue = @lock.synchronize { @raw_inputs.delete(sid) }
128
+ queue&.push(:closed) # unblock a pending read_raw
129
+ end
130
+
131
+ # Synchronous resource call: register a waiter, write the request, and block
132
+ # until the pump delivers the correlated response (or the connection dies).
133
+ def request(resource, method, params = {})
134
+ sid, frame = @connection.call(resource, method, params)
135
+ waiter = Queue.new
136
+ @lock.synchronize { @waiters[sid] = waiter }
137
+ emit(frame)
138
+
139
+ answer = waiter.pop
140
+ # Interrupt wins over a racing connection-closed failure (see read_raw).
141
+ raise Interrupted if interrupted?
142
+ raise answer if answer.is_a?(Exception)
143
+
144
+ unless answer[:ok]
145
+ error = answer[:error] || {}
146
+ raise ResponseError.new(error["code"] || "internal", error["message"] || "request failed")
147
+ end
148
+ answer[:value]
149
+ end
150
+
151
+ # Stop the pump and release the transport.
152
+ def close
153
+ @transport.close
154
+ @pump&.join(2)
155
+ end
156
+
157
+ private
158
+
159
+ def pump
160
+ while (bytes = @transport.read)
161
+ frame =
162
+ begin
163
+ Codec.decode(bytes)
164
+ rescue ProtocolError
165
+ # A single malformed frame is dropped, not fatal — one bad frame must
166
+ # not tear down the whole session (matches the Go client and the Elixir
167
+ # server). The WebSocket transport delimits messages, so the next frame
168
+ # is unaffected. State-machine violations from #receive stay fatal.
169
+ next
170
+ end
171
+ route(@connection.receive(frame))
172
+ end
173
+ # transport closed
174
+ shutdown(ProtocolError.new("client closed before hello"),
175
+ ProtocolError.new("connection closed"))
176
+ rescue StandardError => e
177
+ shutdown(e, e)
178
+ end
179
+
180
+ # Release everyone blocked on the connection: handshake waiter, in-flight
181
+ # requests, and senders blocked on flow credit.
182
+ def shutdown(ready_error, waiter_error)
183
+ signal_ready(ready_error)
184
+ fail_waiters(waiter_error)
185
+ @flow.shutdown(waiter_error)
186
+ # Unblock any read_raw waiting on a dead connection.
187
+ @lock.synchronize { @raw_inputs.values }.each { |queue| queue.push(:closed) }
188
+ end
189
+
190
+ def route(directives)
191
+ directives.each do |kind, *rest|
192
+ case kind
193
+ when :send
194
+ emit(rest[0])
195
+ when :event
196
+ handle_event(rest[0], rest[1])
197
+ end
198
+ end
199
+ end
200
+
201
+ def handle_event(name, payload)
202
+ case name
203
+ when :ready
204
+ @program = payload[:program]
205
+ @entitlement = payload[:entitlement]
206
+ @terminal.apply(payload[:terminal])
207
+ @client_window = payload.dig(:flow, "window") || Protocol::DEFAULT_WINDOW
208
+ signal_ready(:ok)
209
+ when :incompatible
210
+ signal_ready(ProtocolError.new("incompatible client"))
211
+ when :response
212
+ waiter = @lock.synchronize { @waiters.delete(payload[:sid]) }
213
+ waiter&.push(payload)
214
+ when :resize
215
+ @terminal.resize(cols: payload[:cols], rows: payload[:rows])
216
+ @on_resize&.call(@terminal)
217
+ when :interrupt
218
+ # Deliver Ctrl-C into the CLI thread, like a local SIGINT — a blocked
219
+ # request/read/sleep unwinds and the Handler turns it into exit 130.
220
+ # Raise Interrupted, NOT Ruby's Interrupt: Interrupt is a SignalException,
221
+ # and raising one into a thread inside a Falcon worker disturbs the async
222
+ # reactor and kills the connection before the exit frame can flush (the
223
+ # client then hangs). A plain Exception subclass interrupts the same
224
+ # blocking calls without touching Falcon's signal machinery. Set the flag
225
+ # BEFORE raising so a blocked read/request that unblocks via a racing
226
+ # connection-close still sees the interrupt (see read_raw).
227
+ @lock.synchronize { @interrupted = true }
228
+ begin
229
+ @cli_thread&.raise(Interrupted.new)
230
+ rescue ThreadError
231
+ nil
232
+ end
233
+ when :window_adjust
234
+ @flow.grant(payload[:sid], payload[:bytes])
235
+ when :input
236
+ queue = @lock.synchronize { @raw_inputs[payload[:sid]] }
237
+ queue&.push(payload[:bytes])
238
+ end
239
+ end
240
+
241
+ # Signal the handshake exactly once.
242
+ def signal_ready(value)
243
+ @lock.synchronize do
244
+ return if @signaled
245
+
246
+ @signaled = true
247
+ end
248
+ @ready.push(value)
249
+ end
250
+
251
+ # Read the interrupt flag under the lock: it's written on the pump thread and
252
+ # read on the CLI thread, so it needs a memory barrier on both sides (not just
253
+ # the GVL) to be visible across threads on every Ruby runtime.
254
+ def interrupted?
255
+ @lock.synchronize { @interrupted }
256
+ end
257
+
258
+ # Unblock every in-flight request so callers don't hang on a dead connection.
259
+ def fail_waiters(error)
260
+ @lock.synchronize do
261
+ @waiters.each_value { |waiter| waiter.push(error) }
262
+ @waiters.clear
263
+ end
264
+ end
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terminalwire::V2
4
+ module Server
5
+ # Adapts a callback/event-loop websocket endpoint to the blocking Handler.
6
+ # The endpoint supplies an `on_send` sink for outgoing frames and forwards
7
+ # each incoming frame to #receive; the CLI runs on a background thread. This is
8
+ # the seam an ActionCable channel or an async-websocket Rack endpoint plugs
9
+ # into (see ../../../README.md).
10
+ #
11
+ # session = Terminalwire::V2::Server::Session.start(
12
+ # cli_class: MyCLI,
13
+ # on_send: ->(bytes) { websocket.send_binary(bytes) }
14
+ # )
15
+ # websocket.on_message { |bytes| session.receive(bytes) }
16
+ # websocket.on_close { session.close }
17
+ class Session
18
+ def self.start(cli_class:, on_send:, report: nil, verbose: false)
19
+ new(cli_class: cli_class, on_send: on_send, report: report, verbose: verbose).tap(&:start)
20
+ end
21
+
22
+ def initialize(cli_class:, on_send:, report: nil, verbose: false)
23
+ @transport = Transport::Queue.new(sink: on_send)
24
+ @handler = Handler.new(cli_class: cli_class, report: report, verbose: verbose)
25
+ end
26
+
27
+ def start
28
+ @thread = Thread.new do
29
+ @handler.call(transport: @transport)
30
+ ensure
31
+ # Once the worker is done (normal exit or error), close the transport so
32
+ # any further frames the endpoint delivers are dropped instead of piling
33
+ # up in the inbox behind a dead worker.
34
+ @transport.close
35
+ end
36
+ self
37
+ end
38
+
39
+ # Forward a frame received from the client.
40
+ def receive(bytes)
41
+ @transport.deliver(bytes)
42
+ end
43
+
44
+ # End the session and wait briefly for the worker to finish.
45
+ def close
46
+ @transport.close
47
+ @thread&.join(2)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terminalwire::V2
4
+ module Server
5
+ # A stand-in for a global IO stream ($stdout/$stderr/$stdin) that dispatches
6
+ # every call to a **fiber-local** target, falling back to the real stream when
7
+ # no command is currently redirecting on this fiber.
8
+ #
9
+ # This is what makes redirection concurrency-safe: instead of mutating the
10
+ # process-global $stdout per command (which interleaves when two run at once),
11
+ # we install ONE router as $stdout for the whole process and let each fiber
12
+ # point its own target at its own client. `Thread.current[]` is fiber-local in
13
+ # Ruby, so this isolates both threaded servers (Puma) and fiber-per-request
14
+ # servers (Falcon).
15
+ class StreamRouter
16
+ # @param key [Symbol] fiber-local key, e.g. :terminalwire_stdout
17
+ # @param fallback [IO] the real stream to use when no redirect is active
18
+ def initialize(key, fallback)
19
+ @key = key
20
+ @fallback = fallback
21
+ end
22
+
23
+ # The stream this fiber's calls should go to right now.
24
+ def __target__ = Thread.current[@key] || @fallback
25
+
26
+ # Set/clear the fiber-local target. Returns the previous value so callers
27
+ # can restore exactly (supporting nesting).
28
+ def __bind__(target)
29
+ previous = Thread.current[@key]
30
+ Thread.current[@key] = target
31
+ previous
32
+ end
33
+
34
+ def __restore__(previous) = Thread.current[@key] = previous
35
+
36
+ # Delegate everything to the current target. We forward the common methods
37
+ # explicitly (fast path + clear intent) and method_missing the long tail so
38
+ # the router is a faithful IO stand-in for whatever a CLI library calls.
39
+ %i[print puts write << printf p flush sync sync= gets getpass read
40
+ each_line each tty? isatty winsize fileno print_line].each do |m|
41
+ define_method(m) do |*args, &block|
42
+ __target__.public_send(m, *args, &block)
43
+ end
44
+ end
45
+
46
+ def respond_to_missing?(name, include_private = false)
47
+ __target__.respond_to?(name, include_private) || super
48
+ end
49
+
50
+ def method_missing(name, *args, &block)
51
+ target = __target__
52
+ if target.respond_to?(name)
53
+ target.public_send(name, *args, &block)
54
+ else
55
+ super
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terminalwire::V2
4
+ module Server
5
+ # The server's live model of the client's terminal. See ../../../TERMINAL.md.
6
+ #
7
+ # Two orthogonal parts, deliberately not conflated:
8
+ # * three Streams (stdin/stdout/stderr), each independently tty/pipe/file/null
9
+ # * one Device (the controlling terminal): size, term, color, encoding, mode
10
+ #
11
+ # Seeded from the hello `terminal` block; the device's size/mode are updated by
12
+ # control frames. Thread-safe: the read pump writes while the CLI reads.
13
+ class Terminal
14
+ # A standard stream and what it's connected to. Kind is fixed for the
15
+ # session (a stream doesn't turn from a pipe into a tty mid-run).
16
+ Stream = Data.define(:kind) do
17
+ def tty? = kind == "tty"
18
+ def pipe? = kind == "pipe"
19
+ def file? = kind == "file"
20
+ def null? = kind == "null"
21
+ end
22
+
23
+ attr_reader :stdin, :stdout, :stderr
24
+
25
+ def initialize(stdin: "tty", stdout: "tty", stderr: "tty",
26
+ cols: 80, rows: 24, xpixels: 0, ypixels: 0,
27
+ term: "", color: "none", encoding: "UTF-8", mode: "cooked")
28
+ @stdin = Stream.new(kind: stdin)
29
+ @stdout = Stream.new(kind: stdout)
30
+ @stderr = Stream.new(kind: stderr)
31
+ @cols = cols
32
+ @rows = rows
33
+ @xpixels = xpixels
34
+ @ypixels = ypixels
35
+ @term = term
36
+ @color = color
37
+ @encoding = encoding
38
+ @mode = mode
39
+ @mutex = Mutex.new
40
+ end
41
+
42
+ # Device attributes.
43
+ def cols = @mutex.synchronize { @cols }
44
+ def rows = @mutex.synchronize { @rows }
45
+ def xpixels = @mutex.synchronize { @xpixels }
46
+ def ypixels = @mutex.synchronize { @ypixels }
47
+ def term = @mutex.synchronize { @term }
48
+ def encoding = @mutex.synchronize { @encoding }
49
+ def color = @mutex.synchronize { @color }
50
+ def mode = @mutex.synchronize { @mode }
51
+
52
+ def color? = color != "none"
53
+
54
+ # A controlling terminal device exists iff some stream is a tty.
55
+ def device? = @stdin.tty? || @stdout.tty? || @stderr.tty?
56
+
57
+ # IO#winsize convention: [rows, cols].
58
+ def winsize = @mutex.synchronize { [@rows, @cols] }
59
+
60
+ # Look up a stream by name (:stdin/:stdout/:stderr).
61
+ def stream(name)
62
+ { stdin: @stdin, stdout: @stdout, stderr: @stderr }.fetch(name.to_sym)
63
+ end
64
+
65
+ # Apply a wire `terminal` block (string keys) from a hello frame.
66
+ def apply(block)
67
+ return if block.nil?
68
+
69
+ @stdin = Stream.new(kind: block.dig("stdin", "kind") || @stdin.kind)
70
+ @stdout = Stream.new(kind: block.dig("stdout", "kind") || @stdout.kind)
71
+ @stderr = Stream.new(kind: block.dig("stderr", "kind") || @stderr.kind)
72
+
73
+ device = block["device"]
74
+ return if device.nil?
75
+
76
+ @mutex.synchronize do
77
+ @cols = device["cols"] if device["cols"]
78
+ @rows = device["rows"] if device["rows"]
79
+ @xpixels = device["xpixels"] if device["xpixels"]
80
+ @ypixels = device["ypixels"] if device["ypixels"]
81
+ @term = device["term"] if device["term"]
82
+ @color = device["color"] if device["color"]
83
+ @encoding = device["encoding"] if device["encoding"]
84
+ @mode = device["mode"] if device["mode"]
85
+ end
86
+ end
87
+
88
+ # Apply a resize control frame.
89
+ def resize(cols:, rows:, xpixels: nil, ypixels: nil)
90
+ @mutex.synchronize do
91
+ @cols = cols if cols
92
+ @rows = rows if rows
93
+ @xpixels = xpixels if xpixels
94
+ @ypixels = ypixels if ypixels
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Terminalwire::V2
6
+ module Server
7
+ # Thor integration. Including this in a Thor CLI routes all of Thor's I/O —
8
+ # say, ask, yes?, and bare puts/print/gets inside commands — through the
9
+ # Terminalwire Context instead of the server's real $stdin/$stdout. This is
10
+ # what lets you keep writing an ordinary Thor CLI while it runs on the client.
11
+ #
12
+ # Thor needs this dedicated adapter (rather than the generic Server.redirect)
13
+ # because its shell captures the output streams at construction instead of
14
+ # reading the $stdout/$stderr globals at call time. The byte plumbing is the
15
+ # shared Server::IO.
16
+ module Thor
17
+ # Bare puts/print/gets inside Thor commands. Defined in a module so Thor's
18
+ # method_added hook never sees them and they are not registered as commands.
19
+ module Helpers
20
+ def puts(*args) = shell.context.puts(*args.flatten.map(&:to_s))
21
+ def print(*args) = args.each { |arg| shell.context.print(arg.to_s) }
22
+ def warn(*args) = args.each { |arg| shell.context.warn(arg.to_s) }
23
+ def gets = shell.context.gets
24
+ def getpass = shell.context.getpass
25
+ def context = shell.context
26
+ # The client-side resources, exposed on the CLI instance like v1 (so a Thor
27
+ # command can call `browser.launch(url)`, `file.read(path)`, `env("HOME")`).
28
+ def browser = shell.context.browser
29
+ def file = shell.context.file
30
+ def directory = shell.context.directory
31
+ def env(name) = shell.context.env(name)
32
+ def client = shell.context
33
+ end
34
+
35
+ class Shell < ::Thor::Shell::Basic
36
+ attr_reader :context
37
+
38
+ def initialize(context, *args, **kwargs, &block)
39
+ @context = context
40
+ super(*args, **kwargs, &block)
41
+ end
42
+
43
+ protected
44
+
45
+ def stdout = @stdout ||= Server::IO.new(@context, :stdout)
46
+ def stderr = @stderr ||= Server::IO.new(@context, :stderr)
47
+
48
+ # Override Thor's line-editor input (which hardcodes $stdin) to read from
49
+ # the client through the context. Fixes ask, yes?, no?, and passwords.
50
+ def ask_simply(statement, color, options)
51
+ default = options[:default]
52
+ message = [statement, ("(#{default})" if default), nil].uniq.join(" ")
53
+ stdout.print(prepare_message(message, *Array(color)))
54
+
55
+ result = options.fetch(:echo, true) ? context.gets : context.getpass
56
+ return unless result
57
+
58
+ result = result.strip
59
+ default && result == "" ? default : result
60
+ end
61
+ end
62
+
63
+ def self.included(base)
64
+ base.extend ClassMethods
65
+ base.include Helpers
66
+ end
67
+
68
+ module ClassMethods
69
+ # Dispatch a CLI invocation with a Terminalwire-backed shell.
70
+ def terminalwire(arguments:, context:)
71
+ dispatch(nil, arguments.dup, nil, shell: Shell.new(context)) do |instance|
72
+ yield instance if block_given?
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thread"
4
+
5
+ module Terminalwire::V2
6
+ module Transport
7
+ # A blocking in-memory duplex transport. Two ends share a pair of queues, so
8
+ # one end's #write is the other end's #read. Used for tests and in-process
9
+ # wiring; production uses a WebSocket-backed transport with the same interface
10
+ # (#read -> bytes/nil, #write(bytes), #close).
11
+ class Memory
12
+ EOF = :__eof__
13
+
14
+ def self.pair
15
+ a = ::Queue.new
16
+ b = ::Queue.new
17
+ [new(read_queue: a, write_queue: b), new(read_queue: b, write_queue: a)]
18
+ end
19
+
20
+ def initialize(read_queue:, write_queue:)
21
+ @read_queue = read_queue
22
+ @write_queue = write_queue
23
+ end
24
+
25
+ # @return [String, nil] the next frame's bytes, or nil once closed.
26
+ def read
27
+ value = @read_queue.pop
28
+ value == EOF ? nil : value
29
+ end
30
+
31
+ def write(bytes)
32
+ @write_queue.push(bytes)
33
+ end
34
+
35
+ def close
36
+ @write_queue.push(EOF)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thread"
4
+
5
+ module Terminalwire::V2
6
+ module Transport
7
+ # A queue-backed transport for callback-driven servers (ActionCable, async
8
+ # websocket Rack endpoints, etc.). The endpoint pushes each received frame
9
+ # with #deliver; the blocking Runtime consumes them via #read. Outgoing frames
10
+ # are handed to the sink callable. This bridges an event-loop/callback world to
11
+ # the synchronous server runtime.
12
+ class Queue
13
+ CLOSED = Object.new
14
+
15
+ def initialize(sink:)
16
+ @sink = sink
17
+ @inbox = ::Queue.new
18
+ @mutex = Mutex.new
19
+ @closed = false
20
+ end
21
+
22
+ # Called by the websocket endpoint when a frame arrives from the client.
23
+ def deliver(bytes)
24
+ @mutex.synchronize { @inbox << bytes unless @closed }
25
+ end
26
+
27
+ def read
28
+ value = @inbox.pop
29
+ value.equal?(CLOSED) ? nil : value
30
+ end
31
+
32
+ def write(bytes)
33
+ @sink.call(bytes)
34
+ end
35
+
36
+ def close
37
+ @mutex.synchronize do
38
+ next if @closed
39
+
40
+ @closed = true
41
+ @inbox << CLOSED
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Self-contained (full module nesting) so the gemspec can require just this file
4
+ # without loading the rest of the library or assuming Terminalwire is defined.
5
+ module Terminalwire
6
+ module V2
7
+ VERSION = "2.0.0"
8
+ end
9
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terminalwire::V2
4
+ # The flow-control credit rule, as a pure ledger — no threads, no I/O. This is
5
+ # the *protocol* part of flow control: how much output may be in flight, and how
6
+ # window_adjust extends it. The blocking behaviour when credit runs out is an
7
+ # implementation concern layered on top (see Server::FlowController); the rule
8
+ # itself is deterministic and identical across implementations, so it is
9
+ # exercised by the language-neutral flow corpus in ../../conformance.
10
+ class Window
11
+ attr_reader :available
12
+
13
+ def initialize(size)
14
+ @available = size > Protocol::MAX_WINDOW ? Protocol::MAX_WINDOW : size
15
+ end
16
+
17
+ # The number of bytes that may be sent right now toward a request for `want`:
18
+ # min(want, available). Decrements the window by that amount and returns it.
19
+ def take(want)
20
+ amount = want < @available ? want : @available
21
+ amount = 0 if amount.negative?
22
+ @available -= amount
23
+ amount
24
+ end
25
+
26
+ # Extend the window (a window_adjust arrived), clamped to the protocol ceiling
27
+ # (Protocol::MAX_WINDOW) so a peer can't grow the window without bound.
28
+ def grant(bytes)
29
+ @available += bytes
30
+ @available = Protocol::MAX_WINDOW if @available > Protocol::MAX_WINDOW
31
+ end
32
+ end
33
+ end