libtmux-async 0.1.0.alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +90 -0
- data/lib/libtmux/async/control.rb +345 -0
- data/lib/libtmux/async/process.rb +310 -0
- data/lib/libtmux/async/scope.rb +390 -0
- data/lib/libtmux/async/server.rb +77 -0
- data/lib/libtmux/async/version.rb +7 -0
- data/lib/libtmux/async.rb +55 -0
- data/sig/libtmux-async.rbs +58 -0
- metadata +77 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0fc488892362e6465beab45e218ac90c0422c9b3573a9c3591e56edd0804675c
|
|
4
|
+
data.tar.gz: 712551a6b815aaefddd56f1154d4f437aea07139ba39e7b5cf7383e53af1e0ed
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: eea8060f4f05d4ee8adb9a0c70657d83b0ffc7616675d497fa81a59729b7cb9e3fb452c514978b251c1b0d88cd1716a2aec29ea4f13be9184b8d23be1ede8018
|
|
7
|
+
data.tar.gz: a721e50b34a2352c42e1cafdf2a88cd25f188789855ab7fef38f90bf788ef4205b8ea9775a0768b363c5e4244950f87db0bd4c2f4af6ee6debe54c113d5b3e56
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026- libtmux contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# libtmux-async
|
|
2
|
+
|
|
3
|
+
Run libtmux operations inside an application-owned Async task. This
|
|
4
|
+
gem provides a subprocess facade, ordered mapping, control replies and event
|
|
5
|
+
subscriptions. Imports start no scheduler, tmux server or background task.
|
|
6
|
+
|
|
7
|
+
Install the alpha with `gem install libtmux-async --pre`.
|
|
8
|
+
|
|
9
|
+
The [complete program](../../examples/async_cancel.rb) owns an isolated server
|
|
10
|
+
and its cleanup. This excerpt uses that server and creates the Async root:
|
|
11
|
+
|
|
12
|
+
<!-- example: async_cancel/main -->
|
|
13
|
+
```ruby
|
|
14
|
+
Async do |parent|
|
|
15
|
+
LibTmux::Async.open(parent: parent, server: server) do |scope|
|
|
16
|
+
waiting = parent.async do
|
|
17
|
+
scope.server.run(["wait-for", "-S", "ready", ";", "wait-for", "held"], timeout: 0.5)
|
|
18
|
+
rescue LibTmux::Cancelled => error
|
|
19
|
+
error
|
|
20
|
+
end
|
|
21
|
+
scope.server.wait_for("ready", timeout: 0.5)
|
|
22
|
+
Example.check(scope.diagnostics.fetch(:active_process_slots) == 1, "waiting client lost its slot")
|
|
23
|
+
captures = scope.map(scope.server.list_panes.map(&:ref), concurrency: 2) do |ref|
|
|
24
|
+
scope.server.pane(ref).capture
|
|
25
|
+
end
|
|
26
|
+
Example.check(captures.all?(&:success?), "sibling captures stalled")
|
|
27
|
+
waiting.cancel
|
|
28
|
+
failure = waiting.wait
|
|
29
|
+
Example.check(failure.is_a?(LibTmux::Cancelled), "cancellation lost")
|
|
30
|
+
Example.check(failure.delivery == :possibly_sent, "cancelled dispatch claimed no effects")
|
|
31
|
+
Example.raises(Errno::ECHILD) { Process.waitpid(failure.pid, Process::WNOHANG) }
|
|
32
|
+
Example.check(scope.server.diagnostics.fetch(:admitted_requests).zero?, "cancelled client remains admitted")
|
|
33
|
+
end
|
|
34
|
+
end.wait
|
|
35
|
+
```
|
|
36
|
+
<!-- /example -->
|
|
37
|
+
|
|
38
|
+
Omitting `parent:` uses the existing current Async task. The source server must
|
|
39
|
+
outlive the scope. References keep their source binding identity. Scope exit
|
|
40
|
+
retires its clients and joins its owned tasks; it leaves the borrowed daemon
|
|
41
|
+
alive. A scope rejects use from another thread, process or scheduler. Create a
|
|
42
|
+
separate scope for each scheduler thread. Interactive terminal attachment stays
|
|
43
|
+
on the blocking core facade and raises `UnsupportedFeatureError` on this facade.
|
|
44
|
+
|
|
45
|
+
`scope.server.run` returns the same binary `CommandResult` as core. Its stdin,
|
|
46
|
+
stdout and stderr are owned by scheduler tasks. A bounded native helper observes
|
|
47
|
+
and reaps each child only after its final signalling handoff. Cancelling the
|
|
48
|
+
calling task retires its client and raises `Cancelled` with `:not_sent` or
|
|
49
|
+
`:possibly_sent` delivery. An already observed exit completes its bounded drain.
|
|
50
|
+
Repeated cancellation does not restart cleanup deadlines or replace an earlier
|
|
51
|
+
operation failure. Deadlines cannot undo tmux effects.
|
|
52
|
+
|
|
53
|
+
`scope.map` returns a frozen Array in input order; independent requests may
|
|
54
|
+
complete out of order. It caps retained items at 1024 and accounts payload bytes
|
|
55
|
+
in strings, primitive values, `CommandResult`, Arrays and Hashes. Cycles and
|
|
56
|
+
excessive nesting are refused. Application-defined results require an explicit
|
|
57
|
+
`result_bytes:` callable returning a nonnegative Integer. Keep measured values
|
|
58
|
+
unchanged until the map returns. These payload limits complement item counts;
|
|
59
|
+
they are not exact Ruby heap measurements.
|
|
60
|
+
|
|
61
|
+
| Scope limit | Default |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| Active subprocess clients | 4 |
|
|
64
|
+
| Admitted subprocess requests, including unconsumed results | 32 |
|
|
65
|
+
| Queued request payload | 4 MiB |
|
|
66
|
+
| Retained process and map output payload | 8 MiB |
|
|
67
|
+
| Per-command stdout / stderr | 1 MiB / 256 KiB |
|
|
68
|
+
| Control connections | 4 |
|
|
69
|
+
| Ordinary command deadline | 5 seconds |
|
|
70
|
+
|
|
71
|
+
Control connections use their own request, reply and subscriber limits. Obtain
|
|
72
|
+
one with `scope.server.open_control(session: ref)`. `exchange` returns guarded
|
|
73
|
+
`GuardedReply` blocks with `:boundary_window` attribution. It makes no claim of
|
|
74
|
+
final command completion. Outside-block events arrive through `events.next` or
|
|
75
|
+
an explicit `subscribe`; consumer callbacks run outside the parser. Reliable
|
|
76
|
+
subscriptions raise on overflow. Tail subscriptions report dropped ranges.
|
|
77
|
+
Cancelling a dispatched, undrained exchange closes that connection.
|
|
78
|
+
|
|
79
|
+
`pause_output(pane_id:)` and `resume_output(pane_id:)` retain guarded evidence
|
|
80
|
+
and report possible output loss through gap events. A requested-action gap
|
|
81
|
+
does not prove that the action took effect. Close a connection before passing
|
|
82
|
+
it as `reconnect:` to `scope.server.open_control`; the replacement stays owned
|
|
83
|
+
by that scope and reports a new generation plus a gap with unknown loss.
|
|
84
|
+
Subscriptions expose their `generation`; prior subscriptions stay closed.
|
|
85
|
+
Reconnect and resume never replay requests or missed output.
|
|
86
|
+
|
|
87
|
+
The development bundle pins Async 2.46 and io-event 1.22. The
|
|
88
|
+
[compatibility workflow](https://github.com/libtmux/libtmux-ruby/actions/workflows/compatibility.yml)
|
|
89
|
+
exercises the selected Ruby/tmux versions on Linux and macOS and retains
|
|
90
|
+
per-revision results. Package builds and tests do not publish this gem.
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "libtmux/control"
|
|
4
|
+
require "libtmux/async/process"
|
|
5
|
+
|
|
6
|
+
module LibTmux
|
|
7
|
+
module Async
|
|
8
|
+
class ControlSubscription < LibTmux::ControlSubscription
|
|
9
|
+
class Notification < ::Async::Notification
|
|
10
|
+
alias broadcast signal
|
|
11
|
+
end
|
|
12
|
+
private_constant :Notification
|
|
13
|
+
|
|
14
|
+
def initialize(scope:, **options)
|
|
15
|
+
super(**options)
|
|
16
|
+
@scope = scope
|
|
17
|
+
@changed = Notification.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def next(timeout: nil)
|
|
21
|
+
ensure_owner
|
|
22
|
+
unless timeout.nil? || (timeout.is_a?(Numeric) && timeout.finite? && timeout >= 0)
|
|
23
|
+
raise ArgumentError, "timeout must be finite and nonnegative"
|
|
24
|
+
end
|
|
25
|
+
deadline = timeout && clock + timeout
|
|
26
|
+
loop do
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
if @gap
|
|
29
|
+
gap, @gap = @gap, nil
|
|
30
|
+
return gap
|
|
31
|
+
end
|
|
32
|
+
unless @queue.empty?
|
|
33
|
+
event = @queue.shift
|
|
34
|
+
@bytes -= event.bytesize
|
|
35
|
+
return event
|
|
36
|
+
end
|
|
37
|
+
raise @failure if @failure
|
|
38
|
+
raise StopIteration if @closed
|
|
39
|
+
end
|
|
40
|
+
remaining = deadline && deadline - clock
|
|
41
|
+
raise DeadlineExceeded.new("control event deadline elapsed", phase: :subscription) if remaining && remaining <= 0
|
|
42
|
+
|
|
43
|
+
if remaining
|
|
44
|
+
::Async::Task.current.with_timeout(remaining) { @changed.wait }
|
|
45
|
+
else
|
|
46
|
+
@changed.wait
|
|
47
|
+
end
|
|
48
|
+
rescue ::Async::TimeoutError
|
|
49
|
+
raise DeadlineExceeded.new("control event deadline elapsed", phase: :subscription)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def ensure_owner
|
|
56
|
+
@scope.__send__(:ensure_owner)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class ControlConnection < LibTmux::ControlConnection
|
|
61
|
+
def initialize(scope:, binding:, session_id:, reconnect: nil, **limits)
|
|
62
|
+
@scope = scope
|
|
63
|
+
@writer_changed = ::Async::Notification.new
|
|
64
|
+
@startup_changed = ::Async::Notification.new
|
|
65
|
+
@exchanges, @exchange_changed = {}, ::Async::Notification.new
|
|
66
|
+
initialize_state(binding_key: binding.key, session_id: session_id, reconnect: reconnect, **limits)
|
|
67
|
+
@driver = ControlDriver.new(self, scope, binding.command_prefix + ["-C", "attach-session", "-t", session_id])
|
|
68
|
+
@worker = ::Async::Task.new(scope.__send__(:parent)) { @driver.call }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def exchange_request(line, timeout:, cancel:, flow: nil)
|
|
72
|
+
ensure_owner
|
|
73
|
+
unless line.is_a?(String) && !line.empty? && !line.b.match?(/[\x00\r\n]/n)
|
|
74
|
+
raise ArgumentError, "control input must be one nonempty raw command line without NUL or line endings"
|
|
75
|
+
end
|
|
76
|
+
unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
|
|
77
|
+
raise ArgumentError, "control timeout must be positive and finite"
|
|
78
|
+
end
|
|
79
|
+
if cancel && (!cancel.respond_to?(:reader) || !cancel.respond_to?(:cancelled?))
|
|
80
|
+
raise ArgumentError, "cancel must provide a reader and cancellation state"
|
|
81
|
+
end
|
|
82
|
+
raise CapacityError.new("control command exceeds its byte limit", phase: :admission) if line.bytesize > @max_command
|
|
83
|
+
|
|
84
|
+
deadline = clock + timeout
|
|
85
|
+
request = admit(line, cancel, flow: flow)
|
|
86
|
+
@exchanges[request.id] = true
|
|
87
|
+
watcher = failure = nil
|
|
88
|
+
begin
|
|
89
|
+
if cancel
|
|
90
|
+
watcher = ::Async::Task.new(::Async::Task.current) do
|
|
91
|
+
begin
|
|
92
|
+
Fiber.scheduler.io_wait(cancel.reader, IO::READABLE) unless cancel.cancelled?
|
|
93
|
+
abort_request(request, Cancelled, "control request cancelled") if cancel.cancelled?
|
|
94
|
+
rescue ::Async::Cancel
|
|
95
|
+
nil
|
|
96
|
+
rescue IOError, SystemCallError
|
|
97
|
+
abort_request(request, TransportError, "control cancellation reader failed")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
watcher.run
|
|
101
|
+
end
|
|
102
|
+
loop do
|
|
103
|
+
break if request.result || request.error
|
|
104
|
+
if cancel&.cancelled?
|
|
105
|
+
abort_request(request, Cancelled, "control request cancelled")
|
|
106
|
+
next
|
|
107
|
+
end
|
|
108
|
+
remaining = deadline - clock
|
|
109
|
+
unless remaining.positive?
|
|
110
|
+
abort_request(request, DeadlineExceeded, "control request deadline elapsed")
|
|
111
|
+
next
|
|
112
|
+
end
|
|
113
|
+
Fiber.scheduler.io_wait(request.reader, IO::READABLE, remaining)
|
|
114
|
+
end
|
|
115
|
+
rescue Exception => error
|
|
116
|
+
failure = error unless error.is_a?(::Async::Cancel)
|
|
117
|
+
abort_request(request, Cancelled, "control request interrupted") unless request.result
|
|
118
|
+
ensure
|
|
119
|
+
errors = []
|
|
120
|
+
if watcher
|
|
121
|
+
begin
|
|
122
|
+
watcher.cancel unless watcher.finished?
|
|
123
|
+
rescue ::Async::Cancel
|
|
124
|
+
retry
|
|
125
|
+
end
|
|
126
|
+
@scope.__send__(:join_task, watcher, clock + 0.4, errors)
|
|
127
|
+
end
|
|
128
|
+
begin
|
|
129
|
+
abort_request(request, Cancelled, "control request interrupted")
|
|
130
|
+
ensure
|
|
131
|
+
[request.reader, request.writer].each { |io| io.close unless io.closed? }
|
|
132
|
+
@request_pipes.delete(request.id)
|
|
133
|
+
@queued_bytes -= request.wire.bytesize
|
|
134
|
+
@retained_reply_bytes -= request.bytes
|
|
135
|
+
@exchanges.delete(request.id)
|
|
136
|
+
@exchange_changed.signal
|
|
137
|
+
end
|
|
138
|
+
failure ||= request.error
|
|
139
|
+
self.class.__send__(:attach_cleanup_details, failure, errors) if failure && !errors.empty?
|
|
140
|
+
end
|
|
141
|
+
return request.result if request.result
|
|
142
|
+
raise failure if failure
|
|
143
|
+
end
|
|
144
|
+
private :exchange_request
|
|
145
|
+
|
|
146
|
+
def close(timeout: 0.5)
|
|
147
|
+
ensure_owner
|
|
148
|
+
unless timeout.is_a?(Numeric) && timeout.finite? && timeout >= 0 && timeout <= 0.5
|
|
149
|
+
raise ArgumentError, "control close timeout must be between zero and 0.5 seconds"
|
|
150
|
+
end
|
|
151
|
+
request_close
|
|
152
|
+
errors = []
|
|
153
|
+
deadline = clock + timeout
|
|
154
|
+
@scope.__send__(:join_task, @worker, deadline, errors) if @worker
|
|
155
|
+
until @exchanges.empty?
|
|
156
|
+
remaining = deadline - clock
|
|
157
|
+
unless remaining.positive?
|
|
158
|
+
errors << "control exchange cleanup remains pending"
|
|
159
|
+
break
|
|
160
|
+
end
|
|
161
|
+
begin
|
|
162
|
+
::Async::Task.current.with_timeout(remaining) { @exchange_changed.wait }
|
|
163
|
+
rescue ::Async::Cancel
|
|
164
|
+
next
|
|
165
|
+
rescue ::Async::TimeoutError
|
|
166
|
+
errors << "control exchange cleanup remains pending"
|
|
167
|
+
break
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
errors.concat(@cleanup_errors)
|
|
171
|
+
unless errors.empty?
|
|
172
|
+
raise TransportError.new("Async control cleanup failed", phase: :retire, pid: @pid, cleanup_errors: errors)
|
|
173
|
+
end
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def closed?
|
|
178
|
+
super && @exchanges.empty? && @driver.retired?
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
def start
|
|
184
|
+
@worker.run
|
|
185
|
+
::Async::Task.current.with_timeout(0.4) do
|
|
186
|
+
@startup_changed.wait until @pid || @transport_failure || @worker.finished?
|
|
187
|
+
end
|
|
188
|
+
if !@pid && @transport_failure
|
|
189
|
+
errors = []
|
|
190
|
+
@scope.__send__(:join_task, @worker, clock + 0.4, errors)
|
|
191
|
+
errors.concat(@cleanup_errors)
|
|
192
|
+
self.class.__send__(:attach_cleanup_details, @transport_failure, errors) unless errors.empty?
|
|
193
|
+
raise @transport_failure
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
self
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def retired?
|
|
200
|
+
(!@worker || @worker.finished?) && (!@driver || @driver.retired?) && (!@exchanges || @exchanges.empty?)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def ensure_owner
|
|
204
|
+
@scope.__send__(:ensure_owner)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def build_subscription(**options)
|
|
208
|
+
ControlSubscription.new(scope: @scope, **options)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def wake(writer)
|
|
212
|
+
super if writer
|
|
213
|
+
@writer_changed.signal
|
|
214
|
+
@driver&.__send__(:notify_state)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def receive_bytes(bytes)
|
|
218
|
+
@parser.feed(bytes) { |record| receive(record) }
|
|
219
|
+
@writer_changed.signal
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def transport_stopping?
|
|
223
|
+
@stopping
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def finish_transport(failure, errors = nil)
|
|
227
|
+
@transport_failure ||= failure
|
|
228
|
+
@startup_changed.signal
|
|
229
|
+
if errors
|
|
230
|
+
@cleanup_errors = errors.freeze
|
|
231
|
+
@finished = true
|
|
232
|
+
return
|
|
233
|
+
end
|
|
234
|
+
@stopping = true
|
|
235
|
+
@requests.values.each do |request|
|
|
236
|
+
type = failure ? failure.class : ClosedError
|
|
237
|
+
complete(request, error: type.new(failure ? failure.message : "control connection closed",
|
|
238
|
+
delivery: request.offset.zero? ? :not_sent : :possibly_sent, phase: :control, pid: @pid))
|
|
239
|
+
end
|
|
240
|
+
@queue.clear
|
|
241
|
+
@replies.clear
|
|
242
|
+
@writing = nil
|
|
243
|
+
@subscriptions.each { |subscription| subscription.__send__(:finish, failure) }
|
|
244
|
+
@writer_changed.signal
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
class ControlDriver < ProcessDriver
|
|
248
|
+
def initialize(connection, scope, argv)
|
|
249
|
+
super(scope, nil, argv.freeze, "".b, Float::INFINITY, nil,
|
|
250
|
+
{cleanup_timeout: 0.4, drain_timeout: 0.1})
|
|
251
|
+
@connection = connection
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def call
|
|
255
|
+
failure = nil
|
|
256
|
+
begin
|
|
257
|
+
spawn
|
|
258
|
+
@connection.instance_variable_set(:@pid, @pid)
|
|
259
|
+
@connection.instance_variable_get(:@startup_changed).signal
|
|
260
|
+
start_task { read_control }
|
|
261
|
+
start_task { read_errors }
|
|
262
|
+
start_task { write_control }
|
|
263
|
+
start_task do
|
|
264
|
+
until @child.observed? || @child.observation_error
|
|
265
|
+
@child.reader.read_nonblock(16_384, exception: false)
|
|
266
|
+
Fiber.scheduler.io_wait(@child.reader, IO::READABLE) unless @child.observed? || @child.observation_error
|
|
267
|
+
end
|
|
268
|
+
raise TransportError.new("control exit observation failed", phase: :wait, pid: @pid) if @child.observation_error
|
|
269
|
+
|
|
270
|
+
@exit_deadline = clock + 0.1
|
|
271
|
+
end
|
|
272
|
+
until @connection.__send__(:transport_stopping?)
|
|
273
|
+
raise @failure if @failure
|
|
274
|
+
|
|
275
|
+
if @exit_deadline
|
|
276
|
+
remaining = @exit_deadline - clock
|
|
277
|
+
raise TransportError.new("control client exited while a pipe remained open", phase: :read) unless remaining.positive?
|
|
278
|
+
|
|
279
|
+
::Async::Task.current.with_timeout(remaining) { @changed.wait }
|
|
280
|
+
else
|
|
281
|
+
@changed.wait
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
rescue Exception => error
|
|
285
|
+
failure = error.is_a?(Error) ? error : TransportError.new("Async control transport failed (#{error.class})", phase: :read, pid: @pid)
|
|
286
|
+
ensure
|
|
287
|
+
@connection.__send__(:finish_transport, failure)
|
|
288
|
+
errors = cleanup
|
|
289
|
+
@connection.__send__(:finish_transport, failure, errors)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
private
|
|
294
|
+
|
|
295
|
+
def notify_state
|
|
296
|
+
@changed.signal
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def read_control
|
|
300
|
+
loop do
|
|
301
|
+
data = @stdout.read_nonblock(16_384, exception: false)
|
|
302
|
+
case data
|
|
303
|
+
when :wait_readable then Fiber.scheduler.io_wait(@stdout, IO::READABLE)
|
|
304
|
+
when nil
|
|
305
|
+
@connection.instance_variable_get(:@parser).finish
|
|
306
|
+
raise TransportError.new("control client output closed", phase: :read, pid: @pid)
|
|
307
|
+
when String
|
|
308
|
+
@connection.__send__(:receive_bytes, data)
|
|
309
|
+
@changed.signal
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def read_errors
|
|
315
|
+
loop do
|
|
316
|
+
data = @stderr.read_nonblock(16_384, exception: false)
|
|
317
|
+
case data
|
|
318
|
+
when :wait_readable then Fiber.scheduler.io_wait(@stderr, IO::READABLE)
|
|
319
|
+
when nil then return
|
|
320
|
+
when String
|
|
321
|
+
@connection.__send__(:receive_stderr, data.bytesize)
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def write_control
|
|
327
|
+
until @connection.__send__(:transport_stopping?)
|
|
328
|
+
request = @connection.__send__(:pending_write)
|
|
329
|
+
unless request
|
|
330
|
+
@connection.instance_variable_get(:@writer_changed).wait
|
|
331
|
+
next
|
|
332
|
+
end
|
|
333
|
+
written = @writer.write_nonblock(request.wire.byteslice(request.offset, 16_384), exception: false)
|
|
334
|
+
if written == :wait_writable
|
|
335
|
+
Fiber.scheduler.io_wait(@writer, IO::WRITABLE)
|
|
336
|
+
else
|
|
337
|
+
request.offset += written
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
private_constant :ControlDriver
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|