libtmux-async 0.1.0.alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +90 -0
- data/lib/libtmux/async/control.rb +345 -0
- data/lib/libtmux/async/process.rb +310 -0
- data/lib/libtmux/async/scope.rb +390 -0
- data/lib/libtmux/async/server.rb +77 -0
- data/lib/libtmux/async/version.rb +7 -0
- data/lib/libtmux/async.rb +55 -0
- data/sig/libtmux-async.rbs +58 -0
- metadata +77 -0
|
@@ -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
|