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,377 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "handler"
|
|
5
|
+
require_relative "../transport/queue"
|
|
6
|
+
# NOTE: the async stack (async, async-websocket) is required lazily, only when a
|
|
7
|
+
# request actually arrives inside an Async reactor (Falcon). Threaded servers
|
|
8
|
+
# (Puma & friends) and the frame parser never load it — see #call / #async_reactor?.
|
|
9
|
+
|
|
10
|
+
module Terminalwire; end
|
|
11
|
+
module Terminalwire::V2
|
|
12
|
+
module Server
|
|
13
|
+
# A Rack endpoint that serves a Terminalwire CLI over a WebSocket. Mounting it
|
|
14
|
+
# is the entire integration:
|
|
15
|
+
#
|
|
16
|
+
# # config/routes.rb
|
|
17
|
+
# mount Terminalwire::V2::Server::Rack.new(MyCLI), at: "/terminal"
|
|
18
|
+
#
|
|
19
|
+
# It runs on threaded servers (Puma, and friends) and on async servers (Falcon)
|
|
20
|
+
# alike. The server runtime is thread-based, so each connection runs its CLI on
|
|
21
|
+
# its own thread; this class owns that thread, the WebSocket framing, and the
|
|
22
|
+
# teardown — the host app never sees any of it.
|
|
23
|
+
#
|
|
24
|
+
# Two server worlds, picked per request by whether we're inside an async
|
|
25
|
+
# reactor (Async::Task.current?):
|
|
26
|
+
#
|
|
27
|
+
# * Threaded (Puma): a raw RFC 6455 upgrade whose streaming body does the
|
|
28
|
+
# framing on plain blocking socket I/O in threads — exactly what the
|
|
29
|
+
# thread-based runtime and Puma's socket want (no async reactor fighting
|
|
30
|
+
# Puma's write-timeout watchdog).
|
|
31
|
+
# * Async (Falcon): async-websocket drives the connection in reactor fibers,
|
|
32
|
+
# bridged to the runtime's threads via a queue + a wake pipe.
|
|
33
|
+
#
|
|
34
|
+
# Opt-in require: require "terminalwire/v2/server/rack". The async stack is
|
|
35
|
+
# pulled in lazily and only on the Falcon path, so Puma deployments (and the
|
|
36
|
+
# frame parser in unit tests) never load async/async-websocket at all.
|
|
37
|
+
class Rack
|
|
38
|
+
WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
39
|
+
|
|
40
|
+
# WebSocket subprotocols this server speaks, best first. The handshake echoes
|
|
41
|
+
# the first one the client also offered (RFC 6455 negotiation) — the v1 handler
|
|
42
|
+
# did this (`protocols: ['ws']`) and some edges/proxies (e.g. Fly) drop a
|
|
43
|
+
# WebSocket whose Sec-WebSocket-Protocol the server never echoes back.
|
|
44
|
+
SUBPROTOCOLS = %w[terminalwire.v2 ws].freeze
|
|
45
|
+
|
|
46
|
+
# @param cli_class [Class] a Thor CLI that includes Terminalwire::V2::Server::Thor
|
|
47
|
+
# @param verbose [Boolean] send full backtraces to the client (dev only)
|
|
48
|
+
# @param report [#call, nil] optional callable invoked with unexpected errors
|
|
49
|
+
def initialize(cli_class, verbose: false, report: nil)
|
|
50
|
+
@handler = Handler.new(cli_class: cli_class, verbose: verbose, report: report)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def call(env)
|
|
54
|
+
return upgrade_required unless websocket?(env)
|
|
55
|
+
# The incoming connection profile, captured at the upgrade: host (so URL
|
|
56
|
+
# helpers can build absolute URLs, as v1 did), real client IP, User-Agent,
|
|
57
|
+
# and the raw headers. Threaded into the session for server code + `about`.
|
|
58
|
+
request = request_info(env)
|
|
59
|
+
|
|
60
|
+
if async_reactor?
|
|
61
|
+
# Async server (Falcon): let async-websocket own the connection. Pull the
|
|
62
|
+
# adapter in here — this is the only path that needs the async stack.
|
|
63
|
+
# :nocov: Falcon transport wiring — exercised live by the conformance suite, not units.
|
|
64
|
+
require "async/websocket/adapters/rack"
|
|
65
|
+
Async::WebSocket::Adapters::Rack.open(env, protocols: SUBPROTOCOLS) { |connection| ReactorBridge.new(connection, @handler, request: request).run }
|
|
66
|
+
# :nocov:
|
|
67
|
+
else
|
|
68
|
+
# Threaded server (Puma & friends): hand-roll the upgrade and stream.
|
|
69
|
+
[101, upgrade_headers(env), ThreadBridge.new(@handler, request: request)]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Curate the incoming connection profile from the Rack env: the Host, the real
|
|
76
|
+
# client IP (through Fly/proxies), the User-Agent, and the raw HTTP headers.
|
|
77
|
+
# Read at the upgrade — the client identifies itself in headers before the WS
|
|
78
|
+
# handshake, so the server can log it, route on it, or surface it via `about`.
|
|
79
|
+
def request_info(env)
|
|
80
|
+
{
|
|
81
|
+
host: env["HTTP_HOST"],
|
|
82
|
+
ip: client_ip(env),
|
|
83
|
+
user_agent: env["HTTP_USER_AGENT"],
|
|
84
|
+
headers: http_headers(env),
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Real client IP behind Fly/proxies: Fly-Client-IP, else the first
|
|
89
|
+
# X-Forwarded-For hop, else the direct peer.
|
|
90
|
+
#
|
|
91
|
+
# SECURITY: Fly-Client-IP and X-Forwarded-For are client-settable and are
|
|
92
|
+
# trusted verbatim here — there is no trusted-proxy check. This value is for
|
|
93
|
+
# logging and `about` only; it MUST NOT be used as an authorization input
|
|
94
|
+
# (allow/deny, rate-limit identity, audit-as-proof). A direct client can send
|
|
95
|
+
# any IP it likes. If you deploy behind a proxy that isn't Fly, the first
|
|
96
|
+
# X-Forwarded-For hop is likewise attacker-controlled unless your proxy
|
|
97
|
+
# overwrites it.
|
|
98
|
+
def client_ip(env)
|
|
99
|
+
fwd = env["HTTP_X_FORWARDED_FOR"]
|
|
100
|
+
env["HTTP_FLY_CLIENT_IP"] || (fwd && fwd.split(",").first&.strip) || env["REMOTE_ADDR"]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The client/connection headers worth surfacing — standard request headers,
|
|
104
|
+
# not infra chrome. Everything else (Fly-*, X-Forwarded-*, Via, X-Request-*,
|
|
105
|
+
# the WS handshake nonce/mechanics) is proxy/transport noise and is dropped;
|
|
106
|
+
# the real client IP is still recovered separately (see #client_ip). Matched
|
|
107
|
+
# case-insensitively. Allowlist (not denylist) so new proxy headers can't leak in.
|
|
108
|
+
ALLOWED_HEADERS = %w[
|
|
109
|
+
user-agent host origin accept accept-language accept-encoding
|
|
110
|
+
sec-websocket-protocol sec-websocket-version
|
|
111
|
+
].freeze
|
|
112
|
+
|
|
113
|
+
# Rack's HTTP_* env keys -> human header names (HTTP_USER_AGENT -> User-Agent),
|
|
114
|
+
# filtered to the allowlist above.
|
|
115
|
+
def http_headers(env)
|
|
116
|
+
env.each_with_object({}) do |(k, v), h|
|
|
117
|
+
next unless k.is_a?(String) && k.start_with?("HTTP_")
|
|
118
|
+
name = k.sub(/\AHTTP_/, "").split("_").map(&:capitalize).join("-")
|
|
119
|
+
h[name] = v if ALLOWED_HEADERS.include?(name.downcase)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Are we running inside an Async reactor (Falcon)? If the async gem isn't even
|
|
124
|
+
# loaded we cannot be in a reactor — so this is a threaded server and we never
|
|
125
|
+
# touch async. defined? short-circuits before Async::Task is referenced.
|
|
126
|
+
def async_reactor?
|
|
127
|
+
defined?(Async::Task) && Async::Task.current?
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def websocket?(env)
|
|
131
|
+
env["HTTP_UPGRADE"].to_s.casecmp?("websocket") && env["HTTP_SEC_WEBSOCKET_KEY"]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def upgrade_headers(env)
|
|
135
|
+
accept = [Digest::SHA1.digest("#{env['HTTP_SEC_WEBSOCKET_KEY']}#{WS_GUID}")].pack("m0")
|
|
136
|
+
headers = { "upgrade" => "websocket", "connection" => "Upgrade", "sec-websocket-accept" => accept }
|
|
137
|
+
if (proto = negotiated_subprotocol(env))
|
|
138
|
+
headers["sec-websocket-protocol"] = proto
|
|
139
|
+
end
|
|
140
|
+
headers
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The first subprotocol both we and the client support (RFC 6455), or nil.
|
|
144
|
+
def negotiated_subprotocol(env)
|
|
145
|
+
offered = env["HTTP_SEC_WEBSOCKET_PROTOCOL"].to_s.split(/,\s*/)
|
|
146
|
+
(SUBPROTOCOLS & offered).first
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def upgrade_required
|
|
150
|
+
body = "This endpoint speaks the Terminalwire WebSocket protocol.\n"
|
|
151
|
+
[426, { "content-type" => "text/plain", "connection" => "Upgrade",
|
|
152
|
+
"upgrade" => "websocket", "content-length" => body.bytesize.to_s }, [body]]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# --- threaded path (Puma): minimal RFC 6455 framing ----------------------
|
|
156
|
+
|
|
157
|
+
# Just the framing this needs: encode unmasked server->client binary frames;
|
|
158
|
+
# the Sec-WebSocket-Accept value lives on Rack. One WebSocket message == one
|
|
159
|
+
# MessagePack protocol frame.
|
|
160
|
+
module Frame
|
|
161
|
+
CLOSE = [0x88, 0].pack("C2").freeze
|
|
162
|
+
|
|
163
|
+
module_function
|
|
164
|
+
|
|
165
|
+
def binary(payload)
|
|
166
|
+
body = payload.b
|
|
167
|
+
n = body.bytesize
|
|
168
|
+
head =
|
|
169
|
+
if n < 126 then [0x82, n].pack("C2")
|
|
170
|
+
elsif n < 65_536 then [0x82, 126, n].pack("C2n")
|
|
171
|
+
else [0x82, 127, n].pack("C2Q>")
|
|
172
|
+
end
|
|
173
|
+
head + body
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def pong(payload) = [0x8A, payload.bytesize].pack("C2") + payload.b
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Incremental parser: feed raw socket chunks, yield [opcode, payload] per
|
|
180
|
+
# message (reassembling fragments, unmasking — client frames are masked).
|
|
181
|
+
class Parser
|
|
182
|
+
def initialize
|
|
183
|
+
@buf = "".b
|
|
184
|
+
@frag = "".b
|
|
185
|
+
@frag_opcode = nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def push(chunk)
|
|
189
|
+
@buf << chunk.b
|
|
190
|
+
while (frame = next_frame)
|
|
191
|
+
fin, opcode, payload = frame
|
|
192
|
+
case opcode
|
|
193
|
+
when 0x0 # continuation
|
|
194
|
+
@frag << payload
|
|
195
|
+
(yield(@frag_opcode, @frag); @frag = "".b; @frag_opcode = nil) if fin
|
|
196
|
+
when 0x1, 0x2 # text / binary
|
|
197
|
+
if fin then yield(opcode, payload) else @frag_opcode = opcode; @frag = payload.b end
|
|
198
|
+
else # control: 0x8 close, 0x9 ping, 0xA pong
|
|
199
|
+
yield(opcode, payload)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
# A non-destructive read cursor over the buffer. Each read either returns
|
|
207
|
+
# the next bytes and advances, or throws :incomplete — meaning a whole frame
|
|
208
|
+
# isn't buffered yet, so we must leave @buf untouched and wait for more. That
|
|
209
|
+
# "consume nothing until the frame is complete" rule is the parser's one
|
|
210
|
+
# sharp edge; the cursor makes it structural instead of a hand-checked guard
|
|
211
|
+
# before every slice.
|
|
212
|
+
class Cursor
|
|
213
|
+
def initialize(buf)
|
|
214
|
+
@buf = buf
|
|
215
|
+
@off = 0
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def byte
|
|
219
|
+
throw :incomplete if @off >= @buf.bytesize
|
|
220
|
+
b = @buf.getbyte(@off)
|
|
221
|
+
@off += 1
|
|
222
|
+
b
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def take(n)
|
|
226
|
+
throw :incomplete if @buf.bytesize < @off + n
|
|
227
|
+
slice = @buf.byteslice(@off, n)
|
|
228
|
+
@off += n
|
|
229
|
+
slice
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# The unconsumed tail — what's left after a frame is committed.
|
|
233
|
+
def rest = @buf.byteslice(@off..) || "".b
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Decode one frame from @buf, or return nil if a whole frame isn't buffered
|
|
237
|
+
# yet (consuming nothing). Reads top to bottom in RFC 6455 wire order:
|
|
238
|
+
# 2-byte header, optional extended length, optional mask key, then payload.
|
|
239
|
+
# @buf is only advanced (cur.rest) once the full frame is in hand.
|
|
240
|
+
def next_frame
|
|
241
|
+
catch(:incomplete) do
|
|
242
|
+
cur = Cursor.new(@buf)
|
|
243
|
+
b0 = cur.byte
|
|
244
|
+
b1 = cur.byte
|
|
245
|
+
fin = b0.anybits?(0x80)
|
|
246
|
+
opcode = b0 & 0x0F
|
|
247
|
+
masked = b1.anybits?(0x80)
|
|
248
|
+
len = b1 & 0x7F
|
|
249
|
+
len = cur.take(2).unpack1("n") if len == 126
|
|
250
|
+
len = cur.take(8).unpack1("Q>") if len == 127
|
|
251
|
+
key = cur.take(4).bytes if masked
|
|
252
|
+
payload = cur.take(len).b
|
|
253
|
+
@buf = cur.rest
|
|
254
|
+
unmask!(payload, key) if masked
|
|
255
|
+
return [fin, opcode, payload]
|
|
256
|
+
end
|
|
257
|
+
nil # incomplete frame: need more bytes, @buf left untouched
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def unmask!(payload, key)
|
|
261
|
+
i = 0
|
|
262
|
+
n = payload.bytesize
|
|
263
|
+
while i < n
|
|
264
|
+
payload.setbyte(i, payload.getbyte(i) ^ key[i & 3])
|
|
265
|
+
i += 1
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# The streaming body for a threaded server. #call(stream) gets the raw
|
|
271
|
+
# blocking socket after the 101; it runs the CLI on its own thread and pumps
|
|
272
|
+
# the socket on another, then returns so the web server can reuse the worker.
|
|
273
|
+
class ThreadBridge
|
|
274
|
+
def initialize(handler, request: {})
|
|
275
|
+
@handler = handler
|
|
276
|
+
@request = request
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# :nocov: blocking-socket threading — exercised live by the conformance suite, not units.
|
|
280
|
+
def call(stream)
|
|
281
|
+
write_lock = Mutex.new
|
|
282
|
+
parser = Parser.new
|
|
283
|
+
transport = Transport::Queue.new(
|
|
284
|
+
# Serialize writes: the client forbids concurrent writes, and the
|
|
285
|
+
# runtime emits from more than one thread.
|
|
286
|
+
sink: ->(bytes) { write_lock.synchronize { stream.write(Frame.binary(bytes)) } }
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
cli = Thread.new { @handler.call(transport: transport, request: @request) }
|
|
290
|
+
|
|
291
|
+
Thread.new do
|
|
292
|
+
loop do
|
|
293
|
+
parser.push(stream.readpartial(4096)) do |opcode, payload|
|
|
294
|
+
case opcode
|
|
295
|
+
when 0x1, 0x2 then transport.deliver(payload) # protocol frame
|
|
296
|
+
when 0x9 then write_lock.synchronize { stream.write(Frame.pong(payload)) }
|
|
297
|
+
when 0x8 then raise EOFError # client close
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE
|
|
302
|
+
# client went away — normal end
|
|
303
|
+
ensure
|
|
304
|
+
transport.close
|
|
305
|
+
cli.join(2)
|
|
306
|
+
write_lock.synchronize { stream.write(Frame::CLOSE) rescue nil }
|
|
307
|
+
stream.close rescue nil
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
# :nocov:
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# --- async path (Falcon): bridge async-websocket to the thread runtime ----
|
|
314
|
+
|
|
315
|
+
# One WebSocket connection over async-websocket. inbound: connection.read
|
|
316
|
+
# (reactor fiber) -> transport.deliver; outbound: the CLI thread pushes to a
|
|
317
|
+
# Thread::Queue that a writer fiber drains -> connection.
|
|
318
|
+
#
|
|
319
|
+
# The cross-thread hand-off is a plain Thread::Queue, NOT a self-pipe. A fiber
|
|
320
|
+
# blocked in `outbox.pop` yields the reactor, and a `push` from the CLI thread
|
|
321
|
+
# wakes it through the fiber scheduler's own cross-thread wakeup
|
|
322
|
+
# (Async::Scheduler#unblock -> selector.wakeup). We verified this empirically
|
|
323
|
+
# against async 2.39: the reactor keeps running while the writer is parked, and
|
|
324
|
+
# a push from a real OS thread resumes it. An earlier version hand-rolled an
|
|
325
|
+
# IO.pipe to wake the reactor — that just reimplemented selector.wakeup, so it
|
|
326
|
+
# is gone. The CLI runs on a real Thread (not a fiber) on purpose: a user's CLI
|
|
327
|
+
# makes arbitrary blocking calls, which would stall the whole reactor if run as
|
|
328
|
+
# a fiber — Sam's own guidance is to offload blocking work to a thread.
|
|
329
|
+
class ReactorBridge
|
|
330
|
+
JOIN_TIMEOUT = 2
|
|
331
|
+
|
|
332
|
+
def initialize(connection, handler, request: {})
|
|
333
|
+
@connection = connection
|
|
334
|
+
@handler = handler
|
|
335
|
+
@request = request
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# :nocov: reactor-fiber bridge — exercised live by the conformance suite, not units.
|
|
339
|
+
def run
|
|
340
|
+
# Falcon already runs us in a reactor; Sync reuses it (and would create
|
|
341
|
+
# one if absent), giving the connection's fiber I/O a scheduler.
|
|
342
|
+
Sync do |task|
|
|
343
|
+
outbox = ::Queue.new # Thread::Queue: thread-safe + fiber-scheduler aware
|
|
344
|
+
transport = Transport::Queue.new(sink: ->(bytes) { outbox << bytes })
|
|
345
|
+
|
|
346
|
+
cli = Thread.new { @handler.call(transport: transport, request: @request) }
|
|
347
|
+
|
|
348
|
+
writer = task.async do
|
|
349
|
+
# pop blocks the fiber until the CLI thread pushes (cross-thread wakeup
|
|
350
|
+
# via the scheduler); nil means the outbox was closed in teardown.
|
|
351
|
+
while (bytes = outbox.pop)
|
|
352
|
+
@connection.send_binary(bytes)
|
|
353
|
+
@connection.flush if outbox.empty? # batch: flush once the burst drains
|
|
354
|
+
end
|
|
355
|
+
rescue EOFError, IOError, Errno::EPIPE
|
|
356
|
+
# connection died mid-write
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
begin
|
|
360
|
+
while (message = @connection.read)
|
|
361
|
+
transport.deliver(message.buffer.b)
|
|
362
|
+
end
|
|
363
|
+
rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE
|
|
364
|
+
# client disconnected
|
|
365
|
+
end
|
|
366
|
+
ensure
|
|
367
|
+
transport&.close # unblock the handler's pending reads/requests
|
|
368
|
+
cli&.join(JOIN_TIMEOUT) # let it emit the exit frame (writer drains it meanwhile)
|
|
369
|
+
outbox&.close # -> writer's pop returns nil -> writer fiber ends
|
|
370
|
+
writer&.wait
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
# :nocov:
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terminalwire::V2
|
|
4
|
+
module Server
|
|
5
|
+
# Runs a block with Ruby's standard I/O globals pointed at the client's
|
|
6
|
+
# terminal, so an ordinary CLI — OptionParser, GLI, dry-cli, or bare
|
|
7
|
+
# Kernel#puts/gets — streams to/from the client with no Terminalwire-specific
|
|
8
|
+
# code.
|
|
9
|
+
#
|
|
10
|
+
# Terminalwire::V2::Server.redirect(context, argv: args) do
|
|
11
|
+
# OptionParser.new { |o| ... }.parse!(args) # help/errors -> client
|
|
12
|
+
# puts "done" # -> client
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# Concurrency-safe by design. We do NOT swap the process-global $stdout per
|
|
16
|
+
# command (that interleaves when two run at once). Instead we install a
|
|
17
|
+
# StreamRouter as $stdout/$stderr/$stdin ONCE, and each call to #redirect binds
|
|
18
|
+
# a fiber-local target. Two commands running concurrently — on different
|
|
19
|
+
# threads (Puma) or fibers (Falcon) — each see their own client and never cross
|
|
20
|
+
# streams. Outside a #redirect block the routers delegate to the real streams,
|
|
21
|
+
# so installing them is transparent to the rest of the process.
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
STDOUT_KEY = :terminalwire_stdout
|
|
25
|
+
STDERR_KEY = :terminalwire_stderr
|
|
26
|
+
STDIN_KEY = :terminalwire_stdin
|
|
27
|
+
|
|
28
|
+
# Install the routers as the global streams, once per process. Idempotent and
|
|
29
|
+
# thread-safe. Safe to leave installed: with no fiber-local target they pass
|
|
30
|
+
# straight through to the original $stdout/$stderr/$stdin.
|
|
31
|
+
@install_mutex = Mutex.new
|
|
32
|
+
|
|
33
|
+
def install!
|
|
34
|
+
@install_mutex.synchronize do
|
|
35
|
+
return if @installed
|
|
36
|
+
|
|
37
|
+
$stdout = StreamRouter.new(STDOUT_KEY, $stdout)
|
|
38
|
+
$stderr = StreamRouter.new(STDERR_KEY, $stderr)
|
|
39
|
+
$stdin = StreamRouter.new(STDIN_KEY, $stdin)
|
|
40
|
+
@installed = true
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def installed? = @installed == true
|
|
45
|
+
|
|
46
|
+
# Run the block with this fiber's standard streams pointed at `context`.
|
|
47
|
+
#
|
|
48
|
+
# `argv:` is accepted for convenience but ARGV / $PROGRAM_NAME are genuinely
|
|
49
|
+
# process-global (Ruby has no fiber-local ARGV), so we pass the arguments to
|
|
50
|
+
# the block instead of mutating ARGV — the Handler hands them to your CLI
|
|
51
|
+
# directly. We deliberately do NOT mutate global ARGV here, to avoid the exact
|
|
52
|
+
# cross-command races this method exists to prevent.
|
|
53
|
+
def redirect(context, argv: nil)
|
|
54
|
+
install!
|
|
55
|
+
|
|
56
|
+
out = IO.new(context, :stdout)
|
|
57
|
+
err = IO.new(context, :stderr)
|
|
58
|
+
in_ = IO.new(context, :stdin)
|
|
59
|
+
|
|
60
|
+
prev_out = $stdout.__bind__(out)
|
|
61
|
+
prev_err = $stderr.__bind__(err)
|
|
62
|
+
prev_in = $stdin.__bind__(in_)
|
|
63
|
+
|
|
64
|
+
yield(out: out, err: err, in: in_, argv: argv)
|
|
65
|
+
ensure
|
|
66
|
+
$stdout.__restore__(prev_out)
|
|
67
|
+
$stderr.__restore__(prev_err)
|
|
68
|
+
$stdin.__restore__(prev_in)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|