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.
- checksums.yaml +4 -4
- data/lib/terminalwire/v2/codec.rb +52 -0
- data/lib/terminalwire/v2/conformance.rb +229 -0
- data/lib/terminalwire/v2/errors.rb +26 -0
- data/lib/terminalwire/v2/frames.rb +107 -0
- data/lib/terminalwire/v2/mux.rb +48 -0
- data/lib/terminalwire/v2/negotiator.rb +30 -0
- data/lib/terminalwire/v2/protocol.rb +91 -0
- data/lib/terminalwire/v2/rails.rb +335 -0
- data/lib/terminalwire/v2/server/connection.rb +179 -0
- data/lib/terminalwire/v2/server/context.rb +251 -0
- data/lib/terminalwire/v2/server/dual_thor.rb +61 -0
- data/lib/terminalwire/v2/server/flow.rb +77 -0
- data/lib/terminalwire/v2/server/handler.rb +143 -0
- data/lib/terminalwire/v2/server/io.rb +91 -0
- data/lib/terminalwire/v2/server/rack.rb +377 -0
- data/lib/terminalwire/v2/server/redirect.rb +71 -0
- data/lib/terminalwire/v2/server/runtime.rb +267 -0
- data/lib/terminalwire/v2/server/session.rb +51 -0
- data/lib/terminalwire/v2/server/stream_router.rb +60 -0
- data/lib/terminalwire/v2/server/terminal.rb +99 -0
- data/lib/terminalwire/v2/server/thor.rb +78 -0
- data/lib/terminalwire/v2/transport/memory.rb +40 -0
- data/lib/terminalwire/v2/transport/queue.rb +46 -0
- data/lib/terminalwire/v2/version.rb +9 -0
- data/lib/terminalwire/v2/window.rb +33 -0
- data/lib/terminalwire/v2.rb +39 -0
- metadata +140 -23
- data/exe/terminalwire +0 -9
- data/exe/terminalwire-exec +0 -11
|
@@ -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,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
|