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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +139 -0
- data/exe/libtmux-mcp +6 -0
- data/lib/libtmux/mcp/application.rb +519 -0
- data/lib/libtmux/mcp/catalog.rb +258 -0
- data/lib/libtmux/mcp/catalog_tool.rb +33 -0
- data/lib/libtmux/mcp/cli.rb +239 -0
- data/lib/libtmux/mcp/enrollment.rb +670 -0
- data/lib/libtmux/mcp/mutations.rb +120 -0
- data/lib/libtmux/mcp/observation.rb +474 -0
- data/lib/libtmux/mcp/process_identity.rb +271 -0
- data/lib/libtmux/mcp/resources.rb +101 -0
- data/lib/libtmux/mcp/shell/integration.zsh +50 -0
- data/lib/libtmux/mcp/shell/prepare.rb +137 -0
- data/lib/libtmux/mcp/stdio_transport.rb +504 -0
- data/lib/libtmux/mcp/version.rb +7 -0
- data/lib/libtmux/mcp.rb +7 -0
- data/sig/libtmux-mcp.rbs +26 -0
- data/sig/transport.rbs +16 -0
- metadata +136 -0
|
@@ -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
|