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 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