libtmux-mcp 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.
@@ -0,0 +1,670 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'libtmux/mcp/observation'
4
+ require 'digest/sha2'
5
+
6
+ module LibTmux
7
+ module MCP
8
+ # Private protocol for an explicitly enrolled interactive shell.
9
+ class EnrollmentRegistry
10
+ Result = Data.define(:stdout, :stderr, :exit_status, :signal, :receipt)
11
+
12
+ module RunReceipt
13
+ attr_reader :run_receipt, :run_delivery, :run_completion
14
+ end
15
+
16
+ class SocketLease
17
+ def initialize(path)
18
+ @path = path
19
+ end
20
+
21
+ def bind
22
+ @listener = UNIXServer.new(@path)
23
+ end
24
+
25
+ def close
26
+ failure = nil
27
+ begin
28
+ @listener.close if @listener && !@listener.closed?
29
+ rescue Exception => error
30
+ failure = error
31
+ end
32
+ begin
33
+ File.unlink(@path) if File.exist?(@path)
34
+ rescue Exception => error
35
+ ProcessIdentity.attach_cleanup(failure, ["socket path retirement failed (#{error.class})"]) if failure
36
+ failure ||= error
37
+ end
38
+ raise failure if failure
39
+ end
40
+ end
41
+
42
+ class Channel
43
+ def initialize(io)
44
+ @io, @buffer = io, +''.b
45
+ end
46
+
47
+ attr_reader :io
48
+
49
+ def write(line, budget)
50
+ raise ProtocolError.new('enrollment frame exceeds its limit', phase: :write) if line.bytesize > 1024 || line.include?("\n")
51
+
52
+ write_bytes("#{line}\n".b, budget)
53
+ end
54
+
55
+ def write_bytes(bytes, budget)
56
+ offset = 0
57
+ while offset < bytes.bytesize
58
+ remaining = budget.options.fetch(:timeout)
59
+ count = @io.write_nonblock(bytes.byteslice(offset, 16_384), exception: false)
60
+ if count == :wait_writable
61
+ Fiber.scheduler.io_wait(@io, IO::WRITABLE, remaining)
62
+ else
63
+ offset += count
64
+ end
65
+ end
66
+ end
67
+
68
+ def read_bytes(length, budget)
69
+ result = @buffer.slice!(0, length)
70
+ while result.bytesize < length
71
+ remaining = budget.options.fetch(:timeout)
72
+ bytes = @io.read_nonblock([length - result.bytesize, 16_384].min, exception: false)
73
+ if bytes == :wait_readable
74
+ Fiber.scheduler.io_wait(@io, IO::READABLE, remaining)
75
+ elsif bytes
76
+ result << bytes
77
+ else
78
+ raise ClosedError.new('shell protocol channel closed', phase: :read, delivery: :possibly_sent)
79
+ end
80
+ end
81
+ result.freeze
82
+ end
83
+
84
+ def read(budget)
85
+ loop do
86
+ if (ending = @buffer.index("\n"))
87
+ return @buffer.slice!(0, ending + 1).chomp
88
+ end
89
+ raise ProtocolError.new('enrollment frame exceeds its limit', phase: :read) if @buffer.bytesize >= 1024
90
+
91
+ remaining = budget.options.fetch(:timeout)
92
+ bytes = @io.read_nonblock(1024 - @buffer.bytesize, exception: false)
93
+ if bytes == :wait_readable
94
+ Fiber.scheduler.io_wait(@io, IO::READABLE, remaining)
95
+ elsif bytes
96
+ @buffer << bytes
97
+ else
98
+ raise ClosedError.new('shell protocol channel closed', phase: :read)
99
+ end
100
+ end
101
+ end
102
+
103
+ def close
104
+ @io.close unless @io.closed?
105
+ end
106
+ end
107
+
108
+ class Invitation
109
+ attr_reader :reference, :capture, :listener, :path, :token, :expires_at
110
+
111
+ def initialize(reference:, capture:, listener:, path:, expires_at: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 60)
112
+ @reference, @capture, @listener, @path = reference, capture, listener, path.freeze
113
+ @token = SecureRandom.hex(16).freeze
114
+ @expires_at = expires_at
115
+ end
116
+
117
+ def shell_arguments
118
+ raise ClosedError.new('shell invitation is closed', phase: :admission) if @released
119
+ raise DeadlineExceeded.new('shell invitation expired', phase: :admission) if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @expires_at
120
+
121
+ roots = %w[libtmux fiddle digest].flat_map { |name| Gem.loaded_specs.fetch(name).full_require_paths }
122
+ roots.concat([RbConfig::CONFIG.fetch('rubylibdir'), RbConfig::CONFIG.fetch('rubyarchdir')])
123
+ raise UnsupportedFeatureError.new('helper load paths are unsupported', phase: :admission) if roots.any? { |path| path.include?(File::PATH_SEPARATOR) }
124
+
125
+ [File.expand_path('shell/integration.zsh', __dir__), @path, @token, Gem.ruby,
126
+ File.expand_path('shell/prepare.rb', __dir__), roots.uniq.join(File::PATH_SEPARATOR)].map { |value| value.dup.freeze }.freeze
127
+ end
128
+
129
+ def inspect
130
+ "#<#{self.class} closed=#{!!@released}>"
131
+ end
132
+
133
+ def close
134
+ @listener.close unless @listener.closed?
135
+ File.unlink(@path) if File.exist?(@path)
136
+ @capture.close unless @released
137
+ @released = true
138
+ end
139
+ end
140
+
141
+ class Enrollment
142
+ attr_reader :reference, :capture, :channel, :epoch
143
+ attr_accessor :prepared
144
+
145
+ def initialize(reference, capture, channel)
146
+ @reference, @capture, @channel = reference, capture, channel
147
+ @epoch = SecureRandom.hex(16).freeze
148
+ @capture.process.retain
149
+ end
150
+
151
+ def close
152
+ @channel.close
153
+ @capture.close unless @released
154
+ @released = true
155
+ end
156
+ end
157
+
158
+ class Prepared
159
+ attr_reader :reference, :run_id, :process_generation, :receipt, :completion
160
+
161
+ def initialize(registry, enrollment, digest, listener, path)
162
+ @registry, @enrollment, @digest, @listener, @path = registry, enrollment, digest.freeze, listener, path
163
+ @reference = enrollment.reference
164
+ @process_generation = enrollment.capture.process.generation
165
+ @run_id, @token = SecureRandom.hex(16).freeze, SecureRandom.hex(16).freeze
166
+ @authorization = SecureRandom.hex(32).freeze
167
+ @identity = enrollment.capture.process.retain
168
+ end
169
+
170
+ def prepare(budget)
171
+ @identity.ensure_live!
172
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + budget.options.fetch(:timeout)
173
+ @sent = true
174
+ @enrollment.channel.write("P #{@run_id} #{@token} #{[@path].pack('m0')} #{deadline} #{@digest}", budget)
175
+ @channel = Channel.new(@registry.__send__(:accept_socket, @listener, budget))
176
+ response = @channel.read(budget).split(' ')
177
+ expected = [@run_id, @token, @digest, @identity.pid.to_s]
178
+ unless response.drop(1) == expected && %w[READY REFUSED].include?(response.first)
179
+ raise ProtocolError.new('prepared helper identity is invalid', phase: :admission)
180
+ end
181
+ raise UnsupportedFeatureError.new('shell editor is not idle and empty', phase: :admission) if response.first == 'REFUSED'
182
+
183
+ @identity.ensure_live!
184
+ self
185
+ end
186
+
187
+ def authorized?
188
+ !!@receipt
189
+ end
190
+
191
+ def authorize(timeout: 0.5, cancel: nil)
192
+ raise ClosedError.new('prepared authorization is single use', phase: :admission) if @attempted || @closed
193
+
194
+ @attempted = true
195
+ @registry.__send__(:perform, timeout, cancel) do |budget|
196
+ @identity.ensure_live!
197
+ @registry.__send__(:guard, @reference, @identity, budget, @authorization) { @grant_attempted = true }
198
+ @receipt = {'state' => 'authorized', 'run_id' => @run_id, 'script_digest' => @digest,
199
+ 'server_generation' => @reference.binding_key, 'pane_id' => @reference.id,
200
+ 'enrollment_generation' => @enrollment.epoch, 'process_generation' => @process_generation}.transform_values(&:freeze).freeze
201
+ @identity.ensure_live!
202
+ @channel.write("GRANT #{@run_id} #{@token} #{@digest}", budget)
203
+ expected = "AUTHORIZED #{@run_id} #{@token} #{@digest}"
204
+ raise ProtocolError.new('prepared helper acknowledgement is invalid', phase: :read, delivery: :possibly_sent) unless @channel.read(budget) == expected
205
+
206
+ @receipt
207
+ end
208
+ end
209
+
210
+ def grant_attempted?
211
+ !!@grant_attempted
212
+ end
213
+
214
+ def execute(script, stdout_limit:, stderr_limit:, timeout:, cancel: nil)
215
+ raise ClosedError.new('prepared script is not authorized or has already been sent', phase: :admission) unless @receipt && !@executed && !@closed
216
+
217
+ @executed = true
218
+ @registry.__send__(:perform, timeout, cancel) do |operation|
219
+ @channel.write("SCRIPT #{@run_id} #{@token} #{@digest} #{script.bytesize} #{stdout_limit} #{stderr_limit}", operation)
220
+ @channel.write_bytes(script, operation)
221
+ response = @channel.read(operation).split(' ')
222
+ unless response[1, 3] == [@run_id, @token, @digest]
223
+ raise ProtocolError.new('script response identity is invalid', phase: :read, delivery: :possibly_sent)
224
+ end
225
+ if response.first == 'ERROR' && response.length == 6
226
+ klass = {'capacity' => CapacityError, 'deadline' => DeadlineExceeded, 'cancelled' => Cancelled,
227
+ 'protocol' => ProtocolError, 'unknown' => OutcomeUnknown}.fetch(response[4], OutcomeUnknown)
228
+ cleanup = response[5] == 'clean' ? [] : ['authored child retirement was incomplete']
229
+ raise klass.new('authored script did not establish completion', phase: :read, delivery: :possibly_sent, cleanup_errors: cleanup)
230
+ end
231
+ unless response.length == 8 && response.first == 'RESULT' && %w[EXIT SIGNAL].include?(response[4]) && response[5, 3].all? { |value| /\A\d{1,9}\z/.match?(value) }
232
+ raise ProtocolError.new('script completion frame is invalid', phase: :read, delivery: :possibly_sent)
233
+ end
234
+ status, out_length, err_length = response[5, 3].map(&:to_i)
235
+ if out_length > stdout_limit || err_length > stderr_limit || status > 255 || (response[4] == 'SIGNAL' && status.zero?)
236
+ raise ProtocolError.new('script completion exceeds its limits', phase: :read, delivery: :possibly_sent)
237
+ end
238
+ @completion = {'state' => response[4] == 'EXIT' ? 'exited' : 'signaled',
239
+ 'exit_status' => response[4] == 'EXIT' ? status : nil, 'signal' => response[4] == 'SIGNAL' ? status : nil}.freeze
240
+ Result.new(stdout: @channel.read_bytes(out_length, operation), stderr: @channel.read_bytes(err_length, operation),
241
+ exit_status: response[4] == 'EXIT' ? status : nil, signal: response[4] == 'SIGNAL' ? status : nil, receipt: @receipt)
242
+ end
243
+ end
244
+
245
+ def close(timeout: 0.4)
246
+ return if @closed
247
+
248
+ @channel&.close
249
+ @listener.close unless @listener.closed?
250
+ File.unlink(@path) if File.exist?(@path)
251
+ if @sent && !@channel
252
+ @registry.__send__(:discard, @enrollment)
253
+ @done = true
254
+ end
255
+ if @sent && !@done
256
+ response = @enrollment.channel.read(@registry.__send__(:budget, timeout, nil)).split(' ')
257
+ unless response.length == 3 && response[0, 2] == ['DONE', @run_id] && /\A\d+\z/.match?(response[2])
258
+ raise ProtocolError.new('prepared helper retirement is invalid', phase: :retire)
259
+ end
260
+ @done = true
261
+ end
262
+ @identity.close unless @released
263
+ @released = true
264
+ @closed = true
265
+ @enrollment.prepared = nil
266
+ @registry.__send__(:release, self)
267
+ nil
268
+ end
269
+ end
270
+
271
+ def initialize(server:, parent:, max_enrollments: 8)
272
+ unless server.is_a?(LibTmux::Async::Server) && parent.is_a?(::Async::Task) && !parent.finished? && parent.root.equal?(Fiber.scheduler)
273
+ raise ArgumentError, 'enrollment requires an application-owned Async server and live parent task'
274
+ end
275
+ raise ArgumentError, 'enrollment capacity must be positive' unless max_enrollments.is_a?(Integer) && max_enrollments.positive?
276
+
277
+ @server, @parent, @capacity = server, parent, max_enrollments
278
+ @thread, @pid, @scheduler = Thread.current, Process.pid, Fiber.scheduler
279
+ @pending, @enrollments, @prepared = [], {}, []
280
+ @reservations, @retiring, @accepting = {}, [], {}
281
+ @calls, @runs, @watchers, @changed = {}, {}, [], ::Async::Notification.new
282
+ @directory = Dir.mktmpdir('libtmux-ruby-enrollment-')
283
+ end
284
+
285
+ def inspect
286
+ "#<#{self.class} closed=#{!!@closed}>"
287
+ end
288
+
289
+ def run(reference, script:, timeout: 0.5, cancel: nil, stdout_limit: 65_536, stderr_limit: 65_536)
290
+ ensure_open
291
+ unless script.is_a?(String) && script.bytesize <= 65_536 && !script.include?("\0")
292
+ raise ArgumentError, 'script must contain at most 65536 bytes without NUL'
293
+ end
294
+ unless [stdout_limit, stderr_limit].all? { |value| value.is_a?(Integer) && value.between?(0, 262_144) }
295
+ raise ArgumentError, 'script output limits must be integers between 0 and 262144'
296
+ end
297
+ script = script.b.freeze
298
+ run_owner = ::Async::Task.current
299
+ raise CapacityError.new('authored run capacity is full', phase: :admission) if @runs.length >= @capacity || @runs.key?(run_owner)
300
+
301
+ @runs[run_owner] = true
302
+ owned_run = true
303
+ operation = budget(timeout, cancel)
304
+ prepared = prepare(reference, script_digest: Digest::SHA256.hexdigest(script), **operation.options)
305
+ prepared.authorize(**operation.options)
306
+ prepared.execute(script, stdout_limit: stdout_limit, stderr_limit: stderr_limit, **operation.options)
307
+ rescue Exception => error
308
+ error.extend(RunReceipt)
309
+ error.instance_variable_set(:@run_delivery, prepared&.grant_attempted? ? error.respond_to?(:delivery) && error.delivery || :possibly_sent : :not_sent)
310
+ if prepared&.receipt
311
+ error.instance_variable_set(:@run_receipt, prepared.receipt)
312
+ error.instance_variable_set(:@run_completion, prepared.completion)
313
+ end
314
+ raise
315
+ ensure
316
+ begin
317
+ if prepared
318
+ primary = $!
319
+ begin
320
+ prepared.close
321
+ rescue Exception => cleanup
322
+ if primary
323
+ ProcessIdentity.attach_cleanup(primary, ["authored helper retirement failed (#{cleanup.class})"])
324
+ else
325
+ cleanup.extend(RunReceipt)
326
+ cleanup.instance_variable_set(:@run_receipt, prepared.receipt)
327
+ cleanup.instance_variable_set(:@run_completion, prepared.completion)
328
+ cleanup.instance_variable_set(:@run_delivery, prepared.completion ? :observed : :possibly_sent)
329
+ raise
330
+ end
331
+ end
332
+ end
333
+ ensure
334
+ if owned_run
335
+ @runs.delete(run_owner)
336
+ @changed.signal
337
+ end
338
+ end
339
+ end
340
+
341
+ def invite(reference, timeout: 0.5, cancel: nil, expires_in: 60)
342
+ ensure_open
343
+ unless expires_in.is_a?(Numeric) && expires_in.finite? && expires_in.positive? && expires_in <= 300
344
+ raise ArgumentError, 'shell invitation lifetime must be positive and at most 300 seconds'
345
+ end
346
+ perform(timeout, cancel) do |operation|
347
+ @server.__send__(:target, reference, :pane)
348
+ @pending.dup.each do |pending|
349
+ next if @accepting.key?(pending) || clock < pending.expires_at
350
+
351
+ retire([pending])
352
+ @pending.delete(pending)
353
+ @reservations.delete(pending.reference)
354
+ end
355
+ if @reservations.length + @enrollments.length >= @capacity || @reservations.key?(reference) || @enrollments.key?(reference)
356
+ raise CapacityError.new('shell enrollment capacity is full', phase: :admission)
357
+ end
358
+ reservation = Object.new
359
+ @reservations[reference] = reservation
360
+ token = Internal::Cancellation.new
361
+ observer = Observation.new(server: @server, arguments: {'target' => wire(reference), 'track' => true,
362
+ 'max_lines' => 1, 'max_bytes' => 1}, timeout: operation.options.fetch(:timeout), cancel: cancel || token, max_snapshot_bytes: 1 << 20)
363
+ _result, capture = observer.capture(expires: Float::INFINITY)
364
+ path = File.join(@directory, SecureRandom.hex(12))
365
+ socket = SocketLease.new(path)
366
+ @retiring << socket
367
+ listener = socket.bind
368
+ invitation = Invitation.new(reference: reference, capture: capture, listener: listener, path: path, expires_at: clock + expires_in)
369
+ @pending << invitation
370
+ @retiring.delete(socket)
371
+ invitation
372
+ ensure
373
+ begin
374
+ retire([observer, token, *(invitation ? [] : [socket, capture])].compact, primary: $!)
375
+ ensure
376
+ @reservations.delete(reference) if reservation && !invitation && @reservations[reference].equal?(reservation)
377
+ end
378
+ end
379
+ end
380
+
381
+ def accept(invitation, timeout: nil, cancel: nil)
382
+ ensure_open
383
+ raise ArgumentError, 'invitation is not pending in this registry' unless @pending.include?(invitation)
384
+ raise CapacityError.new('shell invitation already has an acceptor', phase: :admission) if @accepting.key?(invitation)
385
+
386
+ @accepting[invitation] = true
387
+ accepted_slot = true
388
+ channel = enrollment = nil
389
+ acknowledged = false
390
+ remaining = invitation.expires_at - clock
391
+ timeout = timeout ? [timeout, remaining].min : remaining
392
+ perform(timeout, cancel) do |operation|
393
+ channel = Channel.new(accept_socket(invitation.listener, operation))
394
+ identity = invitation.capture.process
395
+ identity.ensure_live!
396
+ peer_pid = if RUBY_PLATFORM.include?('darwin')
397
+ channel.io.getsockopt(0, 0x002).int # SOL_LOCAL / LOCAL_PEERPID; generation remains the native lease.
398
+ else
399
+ channel.io.getsockopt(Socket::SOL_SOCKET, Socket::SO_PEERCRED).data.unpack1('i')
400
+ end
401
+ hello = channel.read(operation).split(' ')
402
+ unless peer_pid == identity.pid && hello.length == 4 && hello[0, 2] == ['ZLE1', invitation.token] &&
403
+ /\A5\.9(?:\.\d+)?\z/.match?(hello[2]) && hello[3] == invitation.reference.id
404
+ raise UnsupportedFeatureError.new('shell enrollment identity or profile is unsupported', phase: :admission)
405
+ end
406
+ guard(invitation.reference, identity, operation)
407
+ enrollment = Enrollment.new(invitation.reference, invitation.capture, channel)
408
+ @enrollments[enrollment.reference] = enrollment
409
+ retire([invitation])
410
+ channel.write_bytes('A', operation)
411
+ acknowledged = true
412
+ enrollment
413
+ end
414
+ ensure
415
+ if accepted_slot
416
+ begin
417
+ unless acknowledged
418
+ @enrollments.delete(invitation.reference) if @enrollments[invitation.reference].equal?(enrollment)
419
+ retire([enrollment || channel, invitation].compact, primary: $!)
420
+ end
421
+ ensure
422
+ @accepting.delete(invitation)
423
+ @pending.delete(invitation)
424
+ @reservations.delete(invitation.reference)
425
+ end
426
+ end
427
+ end
428
+
429
+ def prepare(reference, script_digest:, timeout: 0.5, cancel: nil)
430
+ ensure_open
431
+ unless script_digest.is_a?(String) && /\A[0-9a-f]{64}\z/.match?(script_digest)
432
+ raise ArgumentError, 'script_digest must be a SHA256 hex digest'
433
+ end
434
+ enrollment = @enrollments[reference]
435
+ raise UnsupportedFeatureError.new('pane has no enrolled shell', phase: :admission) unless enrollment
436
+ raise CapacityError.new('shell already has an active preparation', phase: :admission) if enrollment.prepared
437
+
438
+ perform(timeout, cancel) do |operation|
439
+ raise CapacityError.new('shell already has an active preparation', phase: :admission) if enrollment.prepared
440
+ path = File.join(@directory, SecureRandom.hex(12))
441
+ socket = SocketLease.new(path)
442
+ @retiring << socket
443
+ listener = socket.bind
444
+ prepared = Prepared.new(self, enrollment, script_digest.dup, listener, path)
445
+ enrollment.prepared = prepared
446
+ @prepared << prepared
447
+ @retiring.delete(socket)
448
+ prepared.prepare(operation)
449
+ rescue Exception => error
450
+ begin
451
+ prepared ? prepared.close : retire([socket].compact, primary: error)
452
+ rescue Exception => cleanup
453
+ ProcessIdentity.attach_cleanup(error, ["prepared helper retirement failed (#{cleanup.class})"])
454
+ end
455
+ raise
456
+ end
457
+ end
458
+
459
+ def close(timeout: 0.5)
460
+ ensure_owner
461
+ unless timeout.is_a?(Numeric) && timeout.finite? && timeout.between?(0, 0.5)
462
+ raise ArgumentError, 'enrollment cleanup timeout must be between 0 and 0.5 seconds'
463
+ end
464
+ if @calls.key?(::Async::Task.current) || @runs.key?(::Async::Task.current)
465
+ raise ClosedError.new('cannot close enrollment from an active request', phase: :retire)
466
+ end
467
+
468
+ @closed = true
469
+ deadline = clock + timeout
470
+ errors, interrupted = [], nil
471
+ attempt = lambda do |label, &work|
472
+ begin
473
+ work.call
474
+ rescue ::Async::Cancel => error
475
+ interrupted ||= error
476
+ retry if clock < deadline
477
+ errors << "#{label} remains pending"
478
+ rescue Exception => error
479
+ errors << "#{label} failed (#{error.class})"
480
+ end
481
+ end
482
+ (@calls.keys + @runs.keys).uniq.each do |task|
483
+ attempt.call('request cancellation') { task.cancel unless task.finished? }
484
+ end
485
+ until (@calls.empty? && @runs.empty?) || clock >= deadline
486
+ begin
487
+ ::Async::Task.current.with_timeout(deadline - clock) { @changed.wait }
488
+ rescue ::Async::Cancel => error
489
+ interrupted ||= error
490
+ rescue ::Async::TimeoutError
491
+ break
492
+ end
493
+ end
494
+ if @calls.empty? && @runs.empty?
495
+ @watchers.dup.each do |watcher|
496
+ attempt.call('cancellation watcher retirement') do
497
+ watcher.cancel unless watcher.finished?
498
+ watcher.wait(timeout: [deadline - clock, 0].max) unless watcher.finished?
499
+ @watchers.delete(watcher) if watcher.finished?
500
+ end
501
+ end
502
+ @prepared.dup.each do |prepared|
503
+ attempt.call('prepared shell retirement') { prepared.close(timeout: [deadline - clock, 0].max) }
504
+ end
505
+ @pending.dup.each do |invitation|
506
+ attempt.call('invitation retirement') do
507
+ invitation.close
508
+ @pending.delete(invitation)
509
+ @reservations.delete(invitation.reference)
510
+ end
511
+ end
512
+ @enrollments.dup.each do |reference, enrollment|
513
+ next if enrollment.prepared
514
+
515
+ attempt.call('enrollment retirement') do
516
+ enrollment.close
517
+ @enrollments.delete(reference)
518
+ end
519
+ end
520
+ @retiring.dup.each do |resource|
521
+ attempt.call('shell resource retirement') do
522
+ resource.is_a?(Observation) ? resource.close(timeout: [deadline - clock, 0].max) : resource.close
523
+ @retiring.delete(resource)
524
+ end
525
+ end
526
+ else
527
+ errors << 'admitted shell requests remain active'
528
+ end
529
+ attempt.call('enrollment directory retirement') { Dir.rmdir(@directory) if Dir.exist?(@directory) } if errors.empty?
530
+ if interrupted
531
+ ProcessIdentity.attach_cleanup(interrupted, errors) unless errors.empty?
532
+ raise interrupted
533
+ end
534
+ raise TransportError.new('shell enrollment cleanup remains pending', phase: :retire, cleanup_errors: errors) unless errors.empty?
535
+
536
+ nil
537
+ end
538
+
539
+ private
540
+
541
+ def retire(resources, primary: nil)
542
+ failure = primary
543
+ resources.each do |resource|
544
+ begin
545
+ resource.close
546
+ @retiring.delete(resource)
547
+ rescue Exception => error
548
+ @retiring << resource unless @retiring.include?(resource)
549
+ ProcessIdentity.attach_cleanup(failure, ["shell resource cleanup failed (#{error.class})"]) if failure
550
+ failure ||= error
551
+ end
552
+ end
553
+ raise failure if failure && !primary
554
+ end
555
+
556
+ def perform(timeout, cancel)
557
+ ensure_open
558
+ owner = ::Async::Task.current
559
+ if @calls.length >= @capacity * 2 || @watchers.length >= @capacity * 2
560
+ raise CapacityError.new('shell protocol request capacity is full', phase: :admission)
561
+ end
562
+ raise ClosedError.new('nested shell protocol request', phase: :admission) if @calls.key?(owner)
563
+ raise Cancelled.new('shell protocol request cancelled', phase: :admission) if cancel&.cancelled?
564
+
565
+ @calls[owner] = true
566
+ armed = true
567
+ watcher = failure = result = nil
568
+ begin
569
+ watcher = if cancel
570
+ ::Async::Task.new(@parent) do
571
+ Fiber.scheduler.io_wait(cancel.reader, IO::READABLE) unless cancel.cancelled?
572
+ owner.cancel if armed && cancel.cancelled?
573
+ end
574
+ end
575
+ @watchers << watcher if watcher
576
+ watcher&.run
577
+ result = yield budget(timeout, cancel)
578
+ rescue ::Async::Cancel => error
579
+ failure = (@closed || cancel&.cancelled?) ? Cancelled.new('shell protocol request cancelled', phase: :admission, delivery: :possibly_sent) : error
580
+ rescue Exception => error
581
+ failure = error
582
+ ensure
583
+ armed = false
584
+ deadline = clock + 0.4
585
+ if watcher
586
+ begin
587
+ watcher.cancel unless watcher.finished?
588
+ watcher.wait(timeout: [deadline - clock, 0].max) unless watcher.finished?
589
+ rescue ::Async::Cancel => error
590
+ failure ||= error
591
+ retry if clock < deadline
592
+ rescue Exception => error
593
+ ProcessIdentity.attach_cleanup(failure, ["cancellation watcher cleanup failed (#{error.class})"]) if failure
594
+ failure ||= TransportError.new('cancellation watcher cleanup remains pending', phase: :retire)
595
+ ensure
596
+ @watchers.delete(watcher) if watcher.finished?
597
+ end
598
+ end
599
+ @calls.delete(owner)
600
+ @changed.signal
601
+ end
602
+ raise failure if failure
603
+
604
+ result
605
+ end
606
+
607
+ def discard(enrollment)
608
+ enrollment.close
609
+ @enrollments.delete(enrollment.reference)
610
+ end
611
+
612
+ def wire(ref)
613
+ {'generation' => ref.binding_key, 'kind' => 'pane', 'id' => ref.id}
614
+ end
615
+
616
+ def release(prepared)
617
+ @prepared.delete(prepared)
618
+ end
619
+
620
+ def budget(timeout, cancel)
621
+ ensure_owner
622
+ @server.__send__(:operation_budget, timeout, cancel)
623
+ end
624
+
625
+ def accept_socket(listener, operation)
626
+ loop do
627
+ remaining = operation.options.fetch(:timeout)
628
+ socket = listener.accept_nonblock(exception: false)
629
+ return socket unless socket == :wait_readable
630
+
631
+ Fiber.scheduler.io_wait(listener, IO::READABLE, remaining)
632
+ end
633
+ end
634
+
635
+ def guard(reference, identity, operation, nonce = nil)
636
+ identity.ensure_live!
637
+ names = @server.__send__(:builtin_spellings, 'if-shell', 'wait-for', budget: operation)
638
+ predicate = "\#{&&:\#{==:\#{pane_id},#{reference.id}},\#{&&:\#{==:\#{pane_pid},#{identity.pid}},\#{&&:\#{==:\#{pane_dead_status},},\#{==:\#{pane_dead_signal},}}}}"
639
+ branch = if nonce
640
+ [@server.__send__(:tmux_command, [names.fetch('wait-for'), '-S', nonce]),
641
+ @server.__send__(:tmux_command, [names.fetch('wait-for'), nonce])].join(' ; ')
642
+ else
643
+ ''
644
+ end
645
+ failure = @server.__send__(:tmux_command, [names.fetch('wait-for')])
646
+ yield if block_given?
647
+ @server.__send__(:execute_typed, [names.fetch('if-shell'), '-F', '-t', reference.id, predicate, branch, failure], **operation.options)
648
+ rescue CommandError => error
649
+ raise TargetNotFoundError.new('enrolled shell no longer belongs to the pane', phase: :admission, delivery: :not_sent), cause: nil
650
+ end
651
+
652
+ def ensure_owner
653
+ unless Process.pid == @pid && Thread.current.equal?(@thread) && Fiber.scheduler.equal?(@scheduler)
654
+ raise ClosedError.new('shell enrollment belongs to another scheduler', phase: :admission)
655
+ end
656
+ end
657
+
658
+ def ensure_open
659
+ ensure_owner
660
+ raise ClosedError.new('shell enrollment is closed', phase: :admission) if @closed
661
+ raise CapacityError.new('shell resource retirement remains pending', phase: :admission) unless @retiring.empty?
662
+ end
663
+
664
+ def clock
665
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
666
+ end
667
+ end
668
+ private_constant :EnrollmentRegistry
669
+ end
670
+ end