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.
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async/notification"
4
+ require "libtmux/child"
5
+
6
+ module LibTmux
7
+ module Async
8
+ class ProcessDriver
9
+ CHUNK = 16_384
10
+ private_constant :CHUNK
11
+
12
+ attr_reader :pid, :result
13
+
14
+ def initialize(scope, ticket, argv, input, deadline, cancel, limits)
15
+ @scope, @ticket, @argv, @input = scope, ticket, argv, input
16
+ @deadline, @cancel, @limits = deadline, cancel, limits
17
+ @started = clock
18
+ @changed = ::Async::Notification.new
19
+ @tasks, @streams = [], []
20
+ @buffers = {stdout: +"".b, stderr: +"".b}
21
+ @finished_reads = 0
22
+ end
23
+
24
+ def call
25
+ failure = nil
26
+ begin
27
+ watch_cancellation
28
+ @scope.__send__(:acquire, @ticket, @deadline, method(:check_cancel))
29
+ check_budget(:spawn)
30
+ check_cancel(:spawn)
31
+ spawn
32
+ start_io
33
+ drive
34
+ rescue ::Async::Cancel
35
+ if observed?
36
+ begin
37
+ drive
38
+ rescue Exception => error
39
+ failure = error
40
+ end
41
+ else
42
+ failure = Cancelled.new("command task was cancelled", **details(:read))
43
+ end
44
+ rescue Exception => error
45
+ failure = error
46
+ ensure
47
+ errors = cleanup
48
+ @scope.__send__(:release_active, @ticket) if retired?
49
+ if failure
50
+ Async.__send__(:attach_cleanup, failure, errors)
51
+ elsif !failure && !errors.empty?
52
+ failure = TransportError.new("Async command cleanup failed", **details(:retire), cleanup_errors: errors)
53
+ end
54
+ end
55
+ raise failure if failure
56
+
57
+ @result
58
+ end
59
+
60
+ def retired?
61
+ (!@child || @child_joined) && @tasks.all?(&:finished?)
62
+ end
63
+
64
+ def cleanup
65
+ @retiring = true
66
+ deadline = @cleanup_deadline ||= clock + @limits.fetch(:cleanup_timeout)
67
+ errors = []
68
+ @tasks.each do |task|
69
+ task.cancel unless task.finished?
70
+ rescue ::Async::Cancel
71
+ retry if clock < deadline
72
+ errors << "I/O task cancellation remains pending"
73
+ rescue Exception => error
74
+ errors << "I/O task cancellation failed (#{error.class})"
75
+ end
76
+ @streams.each do |stream|
77
+ stream.close unless stream.closed?
78
+ rescue IOError, SystemCallError => error
79
+ errors << "command descriptor close failed (#{error.class})"
80
+ end
81
+ if @child
82
+ begin
83
+ if @pid && !@child.observed? && !@child.observation_error.is_a?(Errno::ECHILD)
84
+ @child.signal("TERM")
85
+ # Reserve the cleanup budget for reaping; timer grace can overrun it.
86
+ @child.signal("KILL") unless @child.observed?
87
+ end
88
+ rescue SystemCallError => error
89
+ errors << "owned client termination failed (#{error.class})"
90
+ ensure
91
+ @child.finish_signalling
92
+ end
93
+ cleanup_wait(deadline) { @child.complete? }
94
+ if @child.complete?
95
+ begin
96
+ joined = @child.join([deadline - clock, 0].max)
97
+ @child_joined = !!joined
98
+ errors << "native child observer join remains pending" unless joined
99
+ rescue ::Async::Cancel
100
+ retry if clock < deadline
101
+ errors << "native child observer join was interrupted"
102
+ end
103
+ else
104
+ errors << "owned client reaping remains pending after cleanup deadline"
105
+ end
106
+ errors << "child observation failed (#{@child.observation_error.class})" if @child.observation_error
107
+ errors << "child reaping failed (#{@child.retirement_error.class})" if @child.retirement_error
108
+ @child.close
109
+ end
110
+ @tasks.each do |task|
111
+ begin
112
+ task.wait(timeout: [deadline - clock, 0].max) unless task.finished?
113
+ rescue ::Async::Cancel
114
+ retry if clock < deadline
115
+ errors << "I/O task join was interrupted"
116
+ rescue Exception => error
117
+ errors << "I/O task join failed (#{error.class})"
118
+ end
119
+ end
120
+ errors
121
+ end
122
+
123
+ def details(phase)
124
+ {delivery: @result || @status ? :observed : (@pid ? :possibly_sent : :not_sent), phase: phase, pid: @pid}
125
+ end
126
+
127
+ private
128
+
129
+ def clock
130
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
131
+ end
132
+
133
+ def observed?
134
+ !!(@child&.observed? && !@child.observation_error)
135
+ end
136
+
137
+ def watch_cancellation
138
+ return unless @cancel
139
+
140
+ start_task do
141
+ until @cancel.cancelled?
142
+ Fiber.scheduler.io_wait(@cancel.reader, IO::READABLE)
143
+ end
144
+ @cancelled = true
145
+ @scope.__send__(:notify)
146
+ end
147
+ end
148
+
149
+ def check_cancel(phase)
150
+ raise @failure if @failure
151
+
152
+ if (@cancelled || @cancel&.cancelled?) && !observed?
153
+ raise Cancelled.new("command was cancelled", **details(phase))
154
+ end
155
+ end
156
+
157
+ def check_budget(phase)
158
+ if clock >= [@deadline, @drain_deadline || @deadline].min
159
+ raise DeadlineExceeded.new("command deadline exceeded", **details(phase))
160
+ end
161
+ end
162
+
163
+ def pipe
164
+ IO.pipe.tap { |pair| pair.each(&:binmode); @streams.concat(pair) }
165
+ end
166
+
167
+ def spawn
168
+ Thread.handle_interrupt(Exception => :never) do
169
+ input, @writer = pipe
170
+ @stdout, output = pipe
171
+ @stderr, error = pipe
172
+ @child = Internal::OwnedChild.new
173
+ begin
174
+ @pid = Process.spawn({"TMUX" => nil, "TMUX_PANE" => nil}, [@argv.first, @argv.first], *@argv.drop(1),
175
+ in: input, out: output, err: error, close_others: true)
176
+ ensure
177
+ @child.spawned(@pid)
178
+ end
179
+ [input, output, error].each(&:close)
180
+ end
181
+ rescue IOError, SystemCallError => error
182
+ raise TransportError.new("could not start command (#{error.class})", **details(:spawn)), cause: nil
183
+ end
184
+
185
+ def start_task(&block)
186
+ task = ::Async::Task.new(::Async::Task.current) do
187
+ begin
188
+ block.call
189
+ rescue ::Async::Cancel
190
+ @failure ||= Cancelled.new("command I/O task was cancelled", **details(:read)) unless @retiring
191
+ rescue IOError, SystemCallError => error
192
+ @failure ||= TransportError.new("command I/O failed (#{error.class})", **details(:read)) unless @retiring
193
+ rescue Exception => error
194
+ @failure ||= error unless @retiring
195
+ ensure
196
+ @changed.signal
197
+ @scope.__send__(:notify)
198
+ end
199
+ end
200
+ @tasks << task
201
+ task.run
202
+ task
203
+ end
204
+
205
+ def start_io
206
+ start_task { read_stream(@stdout, :stdout) }
207
+ start_task { read_stream(@stderr, :stderr) }
208
+ start_task { write_input }
209
+ start_task do
210
+ until @child.complete?
211
+ @child.reader.read_nonblock(CHUNK, exception: false)
212
+ update_exit
213
+ Fiber.scheduler.io_wait(@child.reader, IO::READABLE) unless @child.complete?
214
+ end
215
+ update_exit
216
+ end
217
+ end
218
+
219
+ def read_stream(io, stream)
220
+ buffer = @buffers.fetch(stream)
221
+ loop do
222
+ remaining = @limits.fetch(stream) - buffer.bytesize
223
+ bytes = io.read_nonblock([CHUNK, remaining + 1].min, exception: false)
224
+ case bytes
225
+ when nil then break
226
+ when :wait_readable then Fiber.scheduler.io_wait(io, IO::READABLE)
227
+ when String
228
+ raise CapacityError.new("command #{stream} exceeded its byte limit", **details(:read)) if bytes.bytesize > remaining
229
+
230
+ @scope.__send__(:retain_output, @ticket, bytes.bytesize)
231
+ buffer << bytes
232
+ end
233
+ end
234
+ @finished_reads += 1
235
+ ensure
236
+ io.close unless io.closed?
237
+ end
238
+
239
+ def write_input
240
+ offset = 0
241
+ while offset < @input.bytesize && !@writer.closed?
242
+ written = @writer.write_nonblock(@input.byteslice(offset, CHUNK), exception: false)
243
+ if written == :wait_writable
244
+ Fiber.scheduler.io_wait(@writer, IO::WRITABLE)
245
+ else
246
+ offset += written
247
+ end
248
+ end
249
+ rescue Errno::EPIPE
250
+ nil
251
+ ensure
252
+ @writer.close unless @writer.closed?
253
+ end
254
+
255
+ def update_exit
256
+ if @child.observation_error
257
+ raise TransportError.new("command exit observation failed (#{@child.observation_error.class})", **details(:wait))
258
+ end
259
+ if @child.observed?
260
+ @drain_deadline ||= clock + @limits.fetch(:drain_timeout)
261
+ @child.finish_signalling
262
+ end
263
+ raise TransportError.new("command reaping failed", **details(:wait)) if @child.retirement_error
264
+
265
+ @status = @child.status
266
+ @changed.signal
267
+ end
268
+
269
+ def drive
270
+ loop do
271
+ begin
272
+ update_exit
273
+ raise @failure if @failure
274
+ if @status && @finished_reads == 2
275
+ check_budget(:publish)
276
+ @result = CommandResult.new(**@buffers, status: @status, pid: @pid, argv: @argv, elapsed_seconds: clock - @started)
277
+ return
278
+ end
279
+ phase = observed? ? :drain : :read
280
+ check_budget(phase)
281
+ check_cancel(phase)
282
+ remaining = [@deadline, @drain_deadline || @deadline].min - clock
283
+ ::Async::Task.current.with_timeout(remaining) { @changed.wait }
284
+ rescue ::Async::TimeoutError
285
+ raise DeadlineExceeded.new("command deadline exceeded", **details(phase))
286
+ rescue ::Async::Cancel
287
+ raise unless observed?
288
+ end
289
+ end
290
+ end
291
+
292
+ def cleanup_wait(deadline)
293
+ until yield
294
+ remaining = deadline - clock
295
+ return unless remaining.positive?
296
+
297
+ begin
298
+ @child.reader.read_nonblock(CHUNK, exception: false)
299
+ Fiber.scheduler.io_wait(@child.reader, IO::READABLE, remaining) unless yield
300
+ rescue ::Async::Cancel
301
+ next
302
+ rescue IOError, SystemCallError
303
+ return
304
+ end
305
+ end
306
+ end
307
+ end
308
+ private_constant :ProcessDriver
309
+ end
310
+ end
@@ -0,0 +1,390 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "libtmux/async/process"
4
+
5
+ module LibTmux
6
+ module Async
7
+ class Scope
8
+ Request = Struct.new(:bytes, :task, :execution, :result, :error, :active, :output_bytes, keyword_init: true)
9
+ private_constant :Request
10
+
11
+ attr_reader :server
12
+
13
+ def initialize(parent:, server:, concurrency: 4, max_requests: 32, max_controls: 4, max_queue_bytes: 1 << 22,
14
+ max_output_bytes: 1 << 23, stdout_limit: 1 << 20, stderr_limit: 1 << 18,
15
+ input_limit: 1 << 20, argv_limit: 1 << 18, cleanup_timeout: 0.5, drain_timeout: 0.5)
16
+ unless ::Async::Task.current? && parent.is_a?(::Async::Task) && !parent.finished? && parent.root.equal?(Fiber.scheduler)
17
+ raise ArgumentError, "parent must be a live task on the current Async scheduler"
18
+ end
19
+ [concurrency, max_requests, max_controls, max_queue_bytes, max_output_bytes, stdout_limit, stderr_limit, input_limit, argv_limit].each do |value|
20
+ raise ArgumentError, "scope limits must be positive Integers" unless value.is_a?(Integer) && value.positive?
21
+ end
22
+ [cleanup_timeout, drain_timeout].each do |value|
23
+ raise ArgumentError, "cleanup deadlines must be positive and finite" unless value.is_a?(Numeric) && value.finite? && value.positive?
24
+ end
25
+ @parent, @thread, @pid, @scheduler = parent, Thread.current, Process.pid, Fiber.scheduler
26
+ @concurrency, @max_requests, @max_queue, @max_output = concurrency, max_requests, max_queue_bytes, max_output_bytes
27
+ @controls, @max_controls = [], max_controls
28
+ @limits = {stdout: stdout_limit, stderr: stderr_limit, input: input_limit, argv: argv_limit,
29
+ cleanup_timeout: cleanup_timeout, drain_timeout: drain_timeout}.freeze
30
+ @requests, @waiting, @maps = [], [], []
31
+ @active = @queued_bytes = @output_bytes = 0
32
+ @changed = ::Async::Notification.new
33
+ @server = Server.new(self, server)
34
+ end
35
+
36
+ def close
37
+ ensure_owner
38
+ if @maps.flatten.include?(::Async::Task.current?)
39
+ raise ClosedError.new("cannot close an Async scope from its active map worker", phase: :retire)
40
+ end
41
+ @closed = true
42
+ deadline = clock + @limits.fetch(:cleanup_timeout) * 2
43
+ errors = []
44
+ @controls.each { |control| control.__send__(:request_close) }
45
+ @maps.flatten.each { |task| cancel_task(task, deadline, errors) }
46
+ @requests.dup.each { |request| cancel_task(request.task, deadline, errors) }
47
+ @maps.flatten.each { |task| join_task(task, deadline, errors) }
48
+ @controls.dup.each do |control|
49
+ begin
50
+ control.close(timeout: (deadline - clock).clamp(0, 0.5))
51
+ @controls.delete(control)
52
+ rescue Exception => error
53
+ errors << "control close failed (#{error.class})"
54
+ errors.concat(error.cleanup_errors) if error.is_a?(Error)
55
+ end
56
+ end
57
+ @requests.dup.each do |request|
58
+ begin
59
+ request.task.wait(timeout: [deadline - clock, 0].max) unless request.task.finished?
60
+ rescue ::Async::Cancel
61
+ retry if clock < deadline
62
+ rescue Exception => error
63
+ errors << "request join failed (#{error.class})"
64
+ end
65
+ if request.execution && !request.execution.retired?
66
+ errors.concat(request.execution.cleanup)
67
+ release_active(request) if request.execution.retired?
68
+ end
69
+ errors.concat(request.error.cleanup_errors) if request.error.is_a?(Error)
70
+ if !request.execution || request.execution.retired?
71
+ release(request)
72
+ else
73
+ errors << "request ownership remains pending"
74
+ end
75
+ end
76
+ raise TransportError.new("Async scope cleanup failed", phase: :retire, cleanup_errors: errors) unless errors.empty?
77
+
78
+ nil
79
+ end
80
+
81
+ def closed?
82
+ ensure_owner
83
+ !!@closed
84
+ end
85
+
86
+ # Frozen counters and limits from this scope, without I/O or payloads.
87
+ # Process slots remain occupied until retirement; they are not a live PID count.
88
+ # Valid after close on the owning thread, process and scheduler.
89
+ def diagnostics
90
+ ensure_owner
91
+ {transport: :async_process, closed: !!@closed, admitted_requests: @requests.length,
92
+ reserved_process_slots: @requests.length,
93
+ waiting_requests: @waiting.length, active_process_slots: @active,
94
+ reserved_request_bytes: @queued_bytes, retained_output_bytes: @output_bytes,
95
+ control_connections: @controls.count { |control| !control.__send__(:retired?) }, maps: @maps.length,
96
+ limits: {concurrency: @concurrency, max_requests: @max_requests, max_controls: @max_controls,
97
+ max_queue_bytes: @max_queue, max_output_bytes: @max_output,
98
+ stdout_limit: @limits.fetch(:stdout), stderr_limit: @limits.fetch(:stderr),
99
+ input_limit: @limits.fetch(:input), argv_limit: @limits.fetch(:argv),
100
+ cleanup_timeout: @limits.fetch(:cleanup_timeout), drain_timeout: @limits.fetch(:drain_timeout),
101
+ close_timeout: @limits.fetch(:cleanup_timeout) * 2}.freeze}.freeze
102
+ end
103
+
104
+ def map(values, concurrency: @concurrency, max_items: 1024, max_bytes: @max_output, result_bytes: nil)
105
+ ensure_open
106
+ unless concurrency.is_a?(Integer) && concurrency.positive? && concurrency <= @max_requests
107
+ raise ArgumentError, "map concurrency must fit scope admission capacity"
108
+ end
109
+ unless [max_items, max_bytes].all? { |value| value.is_a?(Integer) && value.positive? }
110
+ raise ArgumentError, "map limits must be positive Integers"
111
+ end
112
+ raise ArgumentError, "map requires a block" unless block_given?
113
+ raise ArgumentError, "result_bytes must be callable" if result_bytes && !result_bytes.respond_to?(:call)
114
+
115
+ source = values.to_enum
116
+ tasks, results = [], []
117
+ @maps << tasks
118
+ next_index = retained = 0
119
+ failure = nil
120
+ begin
121
+ concurrency.times do
122
+ task = ::Async::Task.new(@parent) do
123
+ begin
124
+ loop do
125
+ break if failure
126
+ value = begin
127
+ source.next
128
+ rescue StopIteration
129
+ break
130
+ end
131
+ index = next_index
132
+ raise CapacityError.new("ordered map item limit reached", phase: :admission) if index >= max_items
133
+
134
+ next_index += 1
135
+ result = yield value
136
+ bytes = result_bytes ? result_bytes.call(result) : retained_bytes(result)
137
+ unless bytes.is_a?(Integer) && bytes >= 0
138
+ raise ArgumentError, "result_bytes must return a nonnegative Integer"
139
+ end
140
+ if retained + bytes > max_bytes || @output_bytes + bytes > @max_output
141
+ raise CapacityError.new("ordered map retained output limit reached", phase: :read, delivery: :observed)
142
+ end
143
+ retained += bytes
144
+ @output_bytes += bytes
145
+ results[index] = result
146
+ end
147
+ rescue Exception => error
148
+ if failure && error.is_a?(Error)
149
+ Async.__send__(:attach_cleanup, failure, error.cleanup_errors)
150
+ end
151
+ failure ||= error
152
+ ensure
153
+ notify
154
+ end
155
+ end
156
+ tasks << task
157
+ task.run
158
+ end
159
+ until tasks.all?(&:finished?)
160
+ break if failure
161
+ @changed.wait
162
+ end
163
+ rescue Exception => error
164
+ failure ||= error
165
+ ensure
166
+ deadline = clock + @limits.fetch(:cleanup_timeout) * 2
167
+ errors = []
168
+ tasks.each { |task| cancel_task(task, deadline, errors) }
169
+ tasks.each { |task| join_task(task, deadline, errors) }
170
+ @maps.delete(tasks) if tasks.all?(&:finished?)
171
+ @output_bytes -= retained
172
+ if failure
173
+ Async.__send__(:attach_cleanup, failure, errors)
174
+ elsif !failure && !errors.empty?
175
+ failure = TransportError.new("ordered map cleanup failed", phase: :retire, cleanup_errors: errors)
176
+ end
177
+ end
178
+ raise failure if failure
179
+
180
+ results.freeze
181
+ end
182
+
183
+ private
184
+
185
+ attr_reader :parent
186
+
187
+ def open_control(binding:, session_id:, **options)
188
+ ensure_open
189
+ @controls.reject!(&:closed?)
190
+ raise CapacityError.new("Async control capacity is exhausted", phase: :admission) if @controls.length >= @max_controls
191
+
192
+ control = ControlConnection.allocate
193
+ @controls << control
194
+ begin
195
+ control.__send__(:initialize, scope: self, binding: binding, session_id: session_id, **options)
196
+ control.__send__(:start)
197
+ rescue Exception
198
+ @controls.delete(control) if control.__send__(:retired?)
199
+ raise
200
+ end
201
+ control
202
+ end
203
+
204
+ def clock
205
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
206
+ end
207
+
208
+ def join_task(task, deadline, errors)
209
+ until task.finished?
210
+ remaining = deadline - clock
211
+ unless remaining.positive?
212
+ errors << "owned task join remains pending"
213
+ break
214
+ end
215
+ begin
216
+ task.wait(timeout: remaining)
217
+ rescue ::Async::Cancel
218
+ next
219
+ rescue Exception => error
220
+ errors << "owned task join failed (#{error.class})"
221
+ break
222
+ end
223
+ end
224
+ end
225
+
226
+ def cancel_task(task, deadline, errors)
227
+ task.cancel unless task.finished?
228
+ rescue ::Async::Cancel
229
+ retry if clock < deadline
230
+ errors << "owned task cancellation remains pending"
231
+ rescue Exception => error
232
+ errors << "owned task cancellation failed (#{error.class})"
233
+ end
234
+
235
+ def retained_bytes(value)
236
+ nodes = 0
237
+ visiting = {}
238
+ measure = lambda do |item, depth|
239
+ nodes += 1
240
+ if nodes > 2048 || depth > 32
241
+ raise CapacityError.new("ordered map result structure limit reached", phase: :read, delivery: :observed)
242
+ end
243
+ case item
244
+ when nil, true, false, Float then 8
245
+ when Integer then [8, (item.bit_length + 7) / 8].max
246
+ when Symbol then item.to_s.bytesize
247
+ when String then item.bytesize
248
+ when CommandResult then measure.call([item.stdout, item.stderr, item.argv], depth + 1)
249
+ when Array, Hash
250
+ if visiting[item.object_id]
251
+ raise CapacityError.new("ordered map result contains a cycle", phase: :read, delivery: :observed)
252
+ end
253
+ visiting[item.object_id] = true
254
+ begin
255
+ if item.is_a?(Array)
256
+ 8 + item.sum { |child| measure.call(child, depth + 1) }
257
+ else
258
+ 8 + item.sum { |key, child| measure.call(key, depth + 1) + measure.call(child, depth + 1) }
259
+ end
260
+ ensure
261
+ visiting.delete(item.object_id)
262
+ end
263
+ else
264
+ raise UnsupportedFeatureError.new("ordered map application results require a result_bytes estimator", phase: :read, delivery: :observed)
265
+ end
266
+ end
267
+ measure.call(value, 0)
268
+ end
269
+
270
+ def ensure_owner
271
+ unless Process.pid == @pid && Thread.current.equal?(@thread) && Fiber.scheduler.equal?(@scheduler)
272
+ raise ClosedError.new("Async scope belongs to another thread, process or scheduler", phase: :admission)
273
+ end
274
+ end
275
+
276
+ def ensure_open
277
+ ensure_owner
278
+ raise ClosedError.new("Async scope is closed", phase: :admission) if @closed || @parent.finished?
279
+ end
280
+
281
+ def execute(argv, input: "".b, timeout: 5.0, cancel: nil)
282
+ ensure_open
283
+ deadline = clock + timeout if timeout.is_a?(Numeric) && timeout.finite?
284
+ raise ArgumentError, "timeout must be finite" unless deadline
285
+ unless argv.is_a?(Array) && !argv.empty? && argv.all? { |arg| arg.is_a?(String) && !arg.include?("\0") }
286
+ raise ArgumentError, "argv must be a nonempty Array of Strings without NUL"
287
+ end
288
+ raise ArgumentError, "input must be a String" unless input.is_a?(String)
289
+ if cancel && (!cancel.respond_to?(:reader) || !cancel.respond_to?(:cancelled?))
290
+ raise ArgumentError, "cancel must provide a reader and cancellation state"
291
+ end
292
+ raise Cancelled.new("command cancelled before admission", phase: :admission) if cancel&.cancelled?
293
+ raise DeadlineExceeded.new("command deadline elapsed before admission", phase: :admission) if clock >= deadline
294
+
295
+ argv_bytes = argv.sum { |arg| arg.bytesize + 1 }
296
+ bytes = input.bytesize + argv_bytes
297
+ if input.bytesize > @limits.fetch(:input) || argv_bytes > @limits.fetch(:argv) ||
298
+ @requests.length >= @max_requests || @queued_bytes + bytes > @max_queue
299
+ raise CapacityError.new("Async request admission limit reached", phase: :admission)
300
+ end
301
+ ticket = Request.new(bytes: bytes, output_bytes: 0)
302
+ @requests << ticket
303
+ @waiting << ticket
304
+ @queued_bytes += bytes
305
+ ticket.execution = ProcessDriver.new(self, ticket, argv.map { |arg| arg.dup.freeze }.freeze,
306
+ input.b.freeze, deadline, cancel, @limits)
307
+ ticket.task = ::Async::Task.new(@parent) do
308
+ begin
309
+ ticket.result = ticket.execution.call
310
+ rescue Exception => error
311
+ ticket.error = error
312
+ ensure
313
+ notify
314
+ end
315
+ end
316
+ begin
317
+ ticket.task.run
318
+ ticket.task.wait
319
+ rescue Exception => error
320
+ unless ticket.result && error.is_a?(::Async::Cancel)
321
+ errors = []
322
+ failure = error unless error.is_a?(::Async::Cancel)
323
+ deadline = clock + @limits.fetch(:cleanup_timeout) * 2
324
+ cancel_task(ticket.task, deadline, errors)
325
+ join_task(ticket.task, deadline, errors)
326
+ ticket.error = failure || ticket.error || Cancelled.new("command caller was cancelled", **ticket.execution.details(:read))
327
+ Async.__send__(:attach_cleanup, ticket.error, errors)
328
+ end
329
+ end
330
+ raise ticket.error if ticket.error
331
+
332
+ ticket.result
333
+ ensure
334
+ if ticket && (!ticket.task || (ticket.task.finished? && ticket.execution.retired?))
335
+ release(ticket)
336
+ end
337
+ end
338
+
339
+ def acquire(ticket, deadline, check_cancel)
340
+ loop do
341
+ check_cancel.call(:admission)
342
+ raise ClosedError.new("Async scope is closed", phase: :admission) if @closed
343
+ raise DeadlineExceeded.new("command deadline elapsed in admission", phase: :admission) if clock >= deadline
344
+
345
+ if @waiting.first.equal?(ticket) && @active < @concurrency
346
+ @waiting.shift
347
+ @active += 1
348
+ ticket.active = true
349
+ notify
350
+ return
351
+ end
352
+ begin
353
+ ::Async::Task.current.with_timeout(deadline - clock) { @changed.wait }
354
+ rescue ::Async::TimeoutError
355
+ raise DeadlineExceeded.new("command deadline elapsed in admission", phase: :admission)
356
+ end
357
+ end
358
+ end
359
+
360
+ def release_active(ticket)
361
+ return unless ticket.active
362
+
363
+ ticket.active = false
364
+ @active -= 1
365
+ notify
366
+ end
367
+
368
+ def retain_output(ticket, bytes)
369
+ if @output_bytes + bytes > @max_output
370
+ raise CapacityError.new("Async retained output limit reached", **ticket.execution.details(:read))
371
+ end
372
+ @output_bytes += bytes
373
+ ticket.output_bytes += bytes
374
+ end
375
+
376
+ def release(ticket)
377
+ return unless @requests.delete(ticket)
378
+
379
+ @waiting.delete(ticket)
380
+ @queued_bytes -= ticket.bytes
381
+ @output_bytes -= ticket.output_bytes
382
+ notify
383
+ end
384
+
385
+ def notify
386
+ @changed.signal
387
+ end
388
+ end
389
+ end
390
+ end