ace-herdr 0.3.2 → 0.5.0
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 +4 -4
- data/.ace-defaults/herdr/config.yml +0 -4
- data/CHANGELOG.md +30 -0
- data/docs/usage.md +65 -32
- data/lib/ace/herdr/cli/commands/inbox.rb +81 -31
- data/lib/ace/herdr/cli/commands/native_source.rb +56 -0
- data/lib/ace/herdr/cli/commands/support.rb +2 -6
- data/lib/ace/herdr/cli.rb +3 -0
- data/lib/ace/herdr/errors.rb +23 -0
- data/lib/ace/herdr/molecules/bounded_process.rb +266 -51
- data/lib/ace/herdr/molecules/delivery_record_store.rb +81 -5
- data/lib/ace/herdr/molecules/guarded_native_origin.rb +59 -0
- data/lib/ace/herdr/molecules/herdr_executor.rb +6 -3
- data/lib/ace/herdr/molecules/inbox_cli_input.rb +36 -0
- data/lib/ace/herdr/molecules/inbox_context_client.rb +88 -0
- data/lib/ace/herdr/molecules/inbox_context_completion_client.rb +119 -0
- data/lib/ace/herdr/molecules/inbox_context_key.rb +63 -0
- data/lib/ace/herdr/molecules/inbox_context_lifetime.rb +122 -0
- data/lib/ace/herdr/molecules/inbox_context_native_process.rb +92 -0
- data/lib/ace/herdr/molecules/inbox_context_service_configuration.rb +183 -0
- data/lib/ace/herdr/molecules/inbox_context_store.rb +159 -0
- data/lib/ace/herdr/molecules/inbox_context_wire.rb +76 -0
- data/lib/ace/herdr/molecules/inbox_direct_effect_binding.rb +92 -0
- data/lib/ace/herdr/molecules/inbox_direct_result.rb +65 -0
- data/lib/ace/herdr/molecules/native_queue_executor.rb +10 -21
- data/lib/ace/herdr/molecules/native_source.rb +189 -0
- data/lib/ace/herdr/molecules/protected_inbox_selection.rb +126 -0
- data/lib/ace/herdr/molecules/protected_native_control.rb +259 -0
- data/lib/ace/herdr/native_source/HERDR-LICENSE +201 -0
- data/lib/ace/herdr/native_source/guarded-prompt.patch +3493 -0
- data/lib/ace/herdr/native_source/selection.json +24 -0
- data/lib/ace/herdr/organisms/deliverer.rb +2 -3
- data/lib/ace/herdr/organisms/inbox.rb +384 -137
- data/lib/ace/herdr/organisms/inbox_context_direct_effects.rb +285 -0
- data/lib/ace/herdr/organisms/inbox_context_effects.rb +30 -0
- data/lib/ace/herdr/organisms/inbox_context_listener.rb +151 -0
- data/lib/ace/herdr/organisms/inbox_context_owner.rb +550 -0
- data/lib/ace/herdr/organisms/inbox_context_server.rb +70 -0
- data/lib/ace/herdr/organisms/inbox_context_service.rb +107 -0
- data/lib/ace/herdr/organisms/inbox_guarded_wake.rb +117 -0
- data/lib/ace/herdr/organisms/native_source_builder.rb +183 -0
- data/lib/ace/herdr/organisms/runtime_adapter.rb +30 -1
- data/lib/ace/herdr/version.rb +1 -1
- data/lib/ace/herdr.rb +5 -28
- metadata +69 -8
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "fiddle"
|
|
4
4
|
require "timeout"
|
|
5
|
+
require "fcntl"
|
|
5
6
|
|
|
6
7
|
module Ace
|
|
7
8
|
module Herdr
|
|
8
9
|
module Molecules
|
|
9
10
|
# Runs a child process in its own process group with a real deadline.
|
|
10
11
|
# Pipes drain with bounded memory and a stalled child is killed at the
|
|
11
|
-
# deadline
|
|
12
|
-
#
|
|
12
|
+
# execution deadline. Cleanup adds at most one second of owned reaping;
|
|
13
|
+
# unconfirmed cleanup transfers eventual reaping and reports uncertainty.
|
|
13
14
|
module BoundedProcess
|
|
14
15
|
# Immutable outcome of one bounded child run.
|
|
15
16
|
Result = Struct.new(:stdout, :stderr, :status, :oversized)
|
|
@@ -23,33 +24,160 @@ module Ace
|
|
|
23
24
|
end
|
|
24
25
|
end
|
|
25
26
|
|
|
27
|
+
CLEANUP_TIMEOUT = 1.0
|
|
28
|
+
DARWIN_GROUP_PID_LIMIT = 4096
|
|
29
|
+
|
|
30
|
+
# No detached waiter may reap this leader before its owned group is
|
|
31
|
+
# signalled: the unreaped child pins its PID even after normal exit.
|
|
32
|
+
class OwnedChild
|
|
33
|
+
attr_reader :pid
|
|
34
|
+
|
|
35
|
+
def initialize(pid = nil)
|
|
36
|
+
@pid, @status, @owned = pid, nil, !pid.nil?
|
|
37
|
+
@group_signalled = false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def start!(command, options)
|
|
41
|
+
raise PostLaunchError, "original child handle cannot be replaced" if started?
|
|
42
|
+
@pid = Process.spawn(*command, **options)
|
|
43
|
+
@owned = true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def group_signalled? = @group_signalled
|
|
47
|
+
|
|
48
|
+
def group_signalled!
|
|
49
|
+
raise PostLaunchError, "original child ownership is unavailable" unless alive?
|
|
50
|
+
@group_signalled = true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def started?
|
|
54
|
+
!@pid.nil?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def alive?
|
|
58
|
+
@owned && @status.nil?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def join(timeout)
|
|
62
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
63
|
+
loop do
|
|
64
|
+
unless @status
|
|
65
|
+
result = Process.waitpid2(@pid, Process::WNOHANG)
|
|
66
|
+
@status = result.last if result
|
|
67
|
+
end
|
|
68
|
+
return self if @status
|
|
69
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
70
|
+
return nil unless remaining.positive?
|
|
71
|
+
sleep [remaining, 0.002].min
|
|
72
|
+
end
|
|
73
|
+
rescue Errno::ECHILD
|
|
74
|
+
@owned = false
|
|
75
|
+
raise PostLaunchError, "original child ownership is unavailable"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def exited?
|
|
79
|
+
BoundedProcess.nonreaping_exit?(@pid)
|
|
80
|
+
rescue Errno::ECHILD
|
|
81
|
+
@owned = false
|
|
82
|
+
raise PostLaunchError, "original child ownership is unavailable"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def release_to_reaper!
|
|
86
|
+
raise PostLaunchError, "original child ownership is unavailable" unless alive?
|
|
87
|
+
Process.detach(@pid)
|
|
88
|
+
@owned = false
|
|
89
|
+
rescue Errno::ECHILD
|
|
90
|
+
@owned = false
|
|
91
|
+
raise PostLaunchError, "original child ownership is unavailable"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def value
|
|
95
|
+
raise PostLaunchError, "owned child has not been reaped" unless @status
|
|
96
|
+
@status
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
26
100
|
module_function
|
|
27
101
|
|
|
28
102
|
# @param argv [Array<String>] child argv (no shell)
|
|
29
103
|
# @param stdin_data [String] payload written to the child's stdin
|
|
30
104
|
# @param timeout_s [Numeric] wall-clock deadline for the whole run
|
|
31
|
-
# @param output_limit [Integer]
|
|
105
|
+
# @param output_limit [Integer] stdout retained byte cap
|
|
106
|
+
# @param stderr_limit [Integer] stderr retained byte cap (defaults to stdout cap)
|
|
107
|
+
# @param cleanup_group [Boolean] signal owned group after observed leader exit, before reaping
|
|
32
108
|
# @return [Result] stdout/stderr are capped; oversized reports truncation
|
|
33
109
|
# @raise [Timeout::Error] when the child outlives the deadline (killed)
|
|
34
110
|
# @raise [SystemCallError] spawn failures only (child never launched)
|
|
35
111
|
# @raise [PostLaunchError] SystemCallError while the child was live
|
|
36
|
-
def call(argv, stdin_data: "", timeout_s:, output_limit: 65_536)
|
|
37
|
-
|
|
112
|
+
def call(argv, stdin_data: "", timeout_s:, output_limit: 65_536, environment: nil, chdir: nil, descriptor_mapping: {}, stderr_limit: output_limit, cleanup_group: false)
|
|
113
|
+
if environment && (!environment.is_a?(Hash) || !environment.all? { |key, value| key.is_a?(String) && value.is_a?(String) })
|
|
114
|
+
raise ArgumentError, "bounded process environment must be explicit strings"
|
|
115
|
+
end
|
|
116
|
+
if chdir && (!chdir.is_a?(String) || !chdir.start_with?("/"))
|
|
117
|
+
raise ArgumentError, "bounded process working directory must be absolute"
|
|
118
|
+
end
|
|
119
|
+
unless descriptor_mapping.is_a?(Hash) && descriptor_mapping.size <= 16 &&
|
|
120
|
+
descriptor_mapping.all? { |descriptor, handle|
|
|
121
|
+
descriptor.is_a?(Integer) && descriptor.between?(3, 63) &&
|
|
122
|
+
handle.is_a?(File) && !handle.closed? && handle.stat.file? &&
|
|
123
|
+
(handle.fcntl(Fcntl::F_GETFL) & Fcntl::O_ACCMODE) == Fcntl::O_RDONLY
|
|
124
|
+
}
|
|
125
|
+
raise ArgumentError, "bounded process descriptors must be explicit read-only regular files"
|
|
126
|
+
end
|
|
127
|
+
unless [output_limit, stderr_limit].all? { |limit| limit.is_a?(Integer) && limit.positive? } &&
|
|
128
|
+
[true, false].include?(cleanup_group)
|
|
129
|
+
raise ArgumentError, "bounded process limits and cleanup mode are invalid"
|
|
130
|
+
end
|
|
131
|
+
selected_descriptors = descriptor_mapping.dup.freeze
|
|
132
|
+
command = environment ? [environment, *argv] : argv
|
|
133
|
+
options = {pgroup: true, close_others: true}.merge(selected_descriptors)
|
|
134
|
+
options[:chdir] = chdir if chdir
|
|
135
|
+
options[:unsetenv_others] = true if environment
|
|
136
|
+
handles = []
|
|
137
|
+
waiter = nil
|
|
138
|
+
begin
|
|
139
|
+
child_stdin, stdin = IO.pipe
|
|
140
|
+
handles.concat([child_stdin, stdin])
|
|
141
|
+
stdout, child_stdout = IO.pipe
|
|
142
|
+
handles.concat([stdout, child_stdout])
|
|
143
|
+
stderr, child_stderr = IO.pipe
|
|
144
|
+
handles.concat([stderr, child_stderr])
|
|
145
|
+
handles.each(&:binmode)
|
|
146
|
+
waiter = OwnedChild.new
|
|
38
147
|
begin
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
waiter.
|
|
47
|
-
raise PostLaunchError,
|
|
148
|
+
waiter.start!(command, options.merge(in: child_stdin, out: child_stdout, err: child_stderr))
|
|
149
|
+
close_error = close_handles([child_stdin, child_stdout, child_stderr])
|
|
150
|
+
raise close_error if close_error
|
|
151
|
+
run_loop(stdin, stdout, stderr, waiter, stdin_data: stdin_data,
|
|
152
|
+
timeout_s: timeout_s, output_limit: output_limit, stderr_limit: stderr_limit,
|
|
153
|
+
cleanup_group: cleanup_group)
|
|
154
|
+
rescue SystemCallError, IOError => error
|
|
155
|
+
raise unless waiter.started?
|
|
156
|
+
raise PostLaunchError, error.message
|
|
157
|
+
ensure
|
|
158
|
+
close_error = close_handles(handles)
|
|
159
|
+
# Closing one pipe cannot prevent original-child cleanup.
|
|
160
|
+
cleanup_child!(waiter) if waiter.started?
|
|
161
|
+
raise PostLaunchError.new(close_error.message), cause: close_error if close_error && waiter.started?
|
|
48
162
|
end
|
|
163
|
+
ensure
|
|
164
|
+
close_handles(handles)
|
|
49
165
|
end
|
|
50
166
|
end
|
|
51
167
|
|
|
52
|
-
def
|
|
168
|
+
def close_handles(handles)
|
|
169
|
+
first_error = nil
|
|
170
|
+
handles.each do |io|
|
|
171
|
+
begin
|
|
172
|
+
io.close unless io.closed?
|
|
173
|
+
rescue SystemCallError, IOError => error
|
|
174
|
+
first_error ||= error
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
first_error
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def run_loop(stdin, stdout, stderr, waiter, stdin_data:, timeout_s:, output_limit:, stderr_limit:, cleanup_group:)
|
|
53
181
|
payload = stdin_data.to_s
|
|
54
182
|
sent = 0
|
|
55
183
|
stdin_closed = payload.empty?
|
|
@@ -58,59 +186,146 @@ module Ace
|
|
|
58
186
|
streams = [stdout, stderr]
|
|
59
187
|
oversized = false
|
|
60
188
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_s
|
|
61
|
-
|
|
62
|
-
streams = [stdout, stderr]
|
|
63
|
-
oversized = false
|
|
64
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_s
|
|
189
|
+
group_cleaned = false
|
|
65
190
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if
|
|
191
|
+
loop do
|
|
192
|
+
if streams.empty? && stdin_closed
|
|
193
|
+
if cleanup_group && !group_cleaned && waiter.exited?
|
|
69
194
|
kill_group(waiter)
|
|
70
|
-
|
|
195
|
+
group_cleaned = true
|
|
71
196
|
end
|
|
197
|
+
break if (!cleanup_group || group_cleaned) && waiter.join(0)
|
|
198
|
+
end
|
|
199
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
200
|
+
if remaining <= 0
|
|
201
|
+
kill_group(waiter)
|
|
202
|
+
raise Timeout::Error, "process did not finish within #{timeout_s}s"
|
|
203
|
+
end
|
|
72
204
|
|
|
73
|
-
|
|
74
|
-
|
|
205
|
+
ready = IO.select(streams, stdin_closed ? [] : [stdin], nil, [remaining, 0.02].min)
|
|
206
|
+
next unless ready
|
|
75
207
|
|
|
76
|
-
|
|
208
|
+
ready.first.each do |io|
|
|
209
|
+
begin
|
|
77
210
|
chunk = io.read_nonblock(4096, exception: false)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
buffer = buffers.fetch(io)
|
|
83
|
-
available = output_limit - buffer.bytesize
|
|
84
|
-
oversized = true if chunk.bytesize > available
|
|
85
|
-
buffer << chunk.byteslice(0, available) if available.positive?
|
|
86
|
-
end
|
|
211
|
+
rescue Errno::EINTR
|
|
212
|
+
# No bytes were returned. Keep the original child/pipe and
|
|
213
|
+
# return to the same absolute-deadline loop before reading again.
|
|
214
|
+
next
|
|
87
215
|
end
|
|
216
|
+
if chunk.nil?
|
|
217
|
+
io.close
|
|
218
|
+
streams.delete(io)
|
|
219
|
+
elsif chunk != :wait_readable
|
|
220
|
+
buffer = buffers.fetch(io)
|
|
221
|
+
limit = io.equal?(stderr) ? stderr_limit : output_limit
|
|
222
|
+
available = limit - buffer.bytesize
|
|
223
|
+
oversized = true if chunk.bytesize > available
|
|
224
|
+
buffer << chunk.byteslice(0, available) if available.positive?
|
|
225
|
+
end
|
|
226
|
+
end
|
|
88
227
|
|
|
89
|
-
|
|
228
|
+
next if stdin_closed || !ready[1].to_a.include?(stdin)
|
|
90
229
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
end
|
|
230
|
+
begin
|
|
231
|
+
# Nonblocking with partial writes: a child that stops reading
|
|
232
|
+
# must never trap us inside one large stdin.write; the loop
|
|
233
|
+
# re-checks the deadline between write attempts.
|
|
234
|
+
written = stdin.write_nonblock(payload.byteslice(sent, payload.bytesize - sent),
|
|
235
|
+
exception: false)
|
|
236
|
+
sent += written if written != :wait_writable
|
|
237
|
+
rescue Errno::EPIPE, IOError
|
|
238
|
+
sent = payload.bytesize
|
|
239
|
+
end
|
|
240
|
+
if sent >= payload.bytesize
|
|
241
|
+
stdin.close
|
|
242
|
+
stdin_closed = true
|
|
105
243
|
end
|
|
244
|
+
end
|
|
106
245
|
|
|
107
246
|
Result.new(buffers.fetch(stdout), buffers.fetch(stderr), waiter.value, oversized)
|
|
108
247
|
end
|
|
109
248
|
|
|
249
|
+
def cleanup_child!(waiter)
|
|
250
|
+
return unless waiter.alive?
|
|
251
|
+
signal_error = nil
|
|
252
|
+
begin
|
|
253
|
+
kill_group(waiter)
|
|
254
|
+
rescue SystemCallError => error
|
|
255
|
+
signal_error = error
|
|
256
|
+
end
|
|
257
|
+
unless waiter.join(CLEANUP_TIMEOUT)
|
|
258
|
+
# Transfer only this still-exclusively-owned child to an eventual
|
|
259
|
+
# reaper. No signal or positive result can use it after transfer.
|
|
260
|
+
waiter.release_to_reaper!
|
|
261
|
+
raise PostLaunchError, "owned child cleanup could not be confirmed within deadline"
|
|
262
|
+
end
|
|
263
|
+
raise PostLaunchError.new(signal_error.message), cause: signal_error if signal_error
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# POSIX waitid observes only this original child's termination and
|
|
267
|
+
# leaves it waitable. No stream/timeout/PID liveness inference is used.
|
|
268
|
+
# Constants: Linux v6.12 include/uapi/linux/wait.h; XNU bsd/sys/wait.h.
|
|
269
|
+
def nonreaping_exit?(pid)
|
|
270
|
+
nowait = if RUBY_PLATFORM.include?("linux")
|
|
271
|
+
0x01000000
|
|
272
|
+
elsif RUBY_PLATFORM.include?("darwin")
|
|
273
|
+
0x00000020
|
|
274
|
+
else
|
|
275
|
+
raise PostLaunchError, "nonreaping child observation is unsupported"
|
|
276
|
+
end
|
|
277
|
+
buffer = Fiddle::Pointer.malloc(512, Fiddle::RUBY_FREE)
|
|
278
|
+
buffer[0, 512] = "\0" * 512
|
|
279
|
+
function = Fiddle::Function.new(Fiddle.dlopen(nil)["waitid"],
|
|
280
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
|
|
281
|
+
result = function.call(1, pid, buffer, 4 | 1 | nowait)
|
|
282
|
+
raise SystemCallError.new("waitid", Fiddle.last_error) unless result.zero?
|
|
283
|
+
buffer[0, Fiddle::SIZEOF_INT].unpack1("i!") == Signal.list.fetch("CHLD")
|
|
284
|
+
rescue Fiddle::DLError => error
|
|
285
|
+
raise PostLaunchError, error.message
|
|
286
|
+
end
|
|
287
|
+
|
|
110
288
|
def kill_group(waiter)
|
|
289
|
+
return unless waiter.alive?
|
|
290
|
+
return if waiter.is_a?(OwnedChild) && waiter.group_signalled?
|
|
111
291
|
Process.kill("KILL", -waiter.pid)
|
|
292
|
+
waiter.group_signalled! if waiter.is_a?(OwnedChild)
|
|
112
293
|
rescue Errno::ESRCH
|
|
113
294
|
nil
|
|
295
|
+
rescue Errno::EPERM
|
|
296
|
+
# XNU killpg1 excludes SZOMB members and can return EPERM for
|
|
297
|
+
# the owned zombie alone. Genuine permission failures still fail:
|
|
298
|
+
# prove the complete group contains only our unreaped child.
|
|
299
|
+
raise unless darwin_singleton_exited_group?(waiter)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def darwin_singleton_exited_group?(waiter)
|
|
303
|
+
return false unless darwin_platform? && waiter.is_a?(OwnedChild) &&
|
|
304
|
+
waiter.alive? && waiter.exited?
|
|
305
|
+
return false unless darwin_group_pids(waiter.pid) == [waiter.pid]
|
|
306
|
+
waiter.alive? && waiter.exited? && waiter.alive?
|
|
307
|
+
rescue SystemCallError, IOError, Fiddle::DLError, PostLaunchError
|
|
308
|
+
false
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def darwin_platform? = RUBY_PLATFORM.include?("darwin")
|
|
312
|
+
|
|
313
|
+
# libproc enumerates allproc AND zombproc under the kernel process
|
|
314
|
+
# list lock. A full buffer is potentially truncated, never proof.
|
|
315
|
+
# This observes only the owned group, not children escaping it.
|
|
316
|
+
def darwin_group_pids(pid)
|
|
317
|
+
library = Fiddle.dlopen("/usr/lib/libproc.dylib")
|
|
318
|
+
function = Fiddle::Function.new(library["proc_listpids"],
|
|
319
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
|
|
320
|
+
capacity = DARWIN_GROUP_PID_LIMIT * Fiddle::SIZEOF_INT
|
|
321
|
+
buffer = Fiddle::Pointer.malloc(capacity, Fiddle::RUBY_FREE)
|
|
322
|
+
buffer[0, capacity] = "\0" * capacity
|
|
323
|
+
# PROC_PGRP_ONLY=2, from XNU bsd/sys/proc_info.h.
|
|
324
|
+
size = function.call(2, pid, buffer, capacity)
|
|
325
|
+
return nil unless size.positive? && size < capacity && (size % Fiddle::SIZEOF_INT).zero?
|
|
326
|
+
pids = buffer[0, size].unpack("i!*")
|
|
327
|
+
return nil unless pids.all?(&:positive?) && pids.uniq == pids
|
|
328
|
+
pids
|
|
114
329
|
end
|
|
115
330
|
end
|
|
116
331
|
end
|
|
@@ -11,6 +11,8 @@ module Ace
|
|
|
11
11
|
# yields a torn record and the answer stays re-deliverable. A per-event
|
|
12
12
|
# flock serializes concurrent deliveries of the same event.
|
|
13
13
|
module DeliveryRecordStore
|
|
14
|
+
class LockUnavailable < StandardError; end
|
|
15
|
+
|
|
14
16
|
module_function
|
|
15
17
|
|
|
16
18
|
# @return [Models::DeliveryRecord, nil] the record, falling back to
|
|
@@ -41,6 +43,12 @@ module Ace
|
|
|
41
43
|
|
|
42
44
|
# @return [String] path the record was written to
|
|
43
45
|
def save(record, deliveries_dir)
|
|
46
|
+
with_inventory_lock(deliveries_dir, exclusive: false, create: true) do
|
|
47
|
+
save_held(record, deliveries_dir)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def save_held(record, deliveries_dir)
|
|
44
52
|
FileUtils.mkdir_p(deliveries_dir)
|
|
45
53
|
path = path_for(deliveries_dir, record.event_id)
|
|
46
54
|
tmp = "#{path}.tmp.#{Process.pid}"
|
|
@@ -56,18 +64,79 @@ module Ace
|
|
|
56
64
|
|
|
57
65
|
# Hold the exclusive per-event lock while delivering; concurrent
|
|
58
66
|
# callers block until the winner finishes, then observe its record.
|
|
59
|
-
def with_lock(deliveries_dir, event_id)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
def with_lock(deliveries_dir, event_id, create: true, deadline: nil)
|
|
68
|
+
with_locks(deliveries_dir, [event_id], create: create, deadline: deadline) { yield }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def with_locks(deliveries_dir, event_ids, create: false, deadline: nil)
|
|
72
|
+
with_inventory_lock(deliveries_dir, exclusive: false, create: create, deadline: deadline) do
|
|
73
|
+
with_event_locks_held(deliveries_dir, event_ids, create: create, deadline: deadline) { yield }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Global inventory always precedes event locks. Normal creators/writers
|
|
78
|
+
# share it; maintenance exclusively holds it through its entire mutation
|
|
79
|
+
# boundary. Reentrancy never upgrades a writer's shared lock.
|
|
80
|
+
def with_inventory_lock(deliveries_dir, exclusive:, create: false, prepare_directory: true, deadline: nil)
|
|
81
|
+
admission_deadline!(deadline) unless deadline.nil?
|
|
82
|
+
held = Thread.current[:ace_herdr_delivery_inventory_locks] ||= {}
|
|
83
|
+
if (existing = held[deliveries_dir])
|
|
84
|
+
raise ArgumentError, "delivery inventory lock cannot be upgraded" if exclusive && existing != :exclusive
|
|
85
|
+
return yield
|
|
86
|
+
end
|
|
87
|
+
FileUtils.mkdir_p(deliveries_dir) if create && prepare_directory
|
|
88
|
+
flags = File::RDWR | File::NOFOLLOW | (create ? File::CREAT : 0)
|
|
89
|
+
flags |= File::NONBLOCK unless deadline.nil?
|
|
90
|
+
File.open(File.join(deliveries_dir, ".inventory.lock"), flags, 0o600) do |lock|
|
|
91
|
+
raise ArgumentError, "delivery inventory lock is not a regular file" unless lock.stat.file?
|
|
92
|
+
acquire_lock!(lock, exclusive ? File::LOCK_EX : File::LOCK_SH, deadline)
|
|
93
|
+
held[deliveries_dir] = exclusive ? :exclusive : :shared
|
|
63
94
|
begin
|
|
64
|
-
|
|
95
|
+
yield
|
|
65
96
|
ensure
|
|
97
|
+
held.delete(deliveries_dir)
|
|
66
98
|
lock.flock(File::LOCK_UN)
|
|
67
99
|
end
|
|
68
100
|
end
|
|
69
101
|
end
|
|
70
102
|
|
|
103
|
+
def with_event_locks_held(deliveries_dir, event_ids, create: false, deadline: nil)
|
|
104
|
+
FileUtils.mkdir_p(deliveries_dir) if create
|
|
105
|
+
flags = create ? "a" : File::RDWR | File::NOFOLLOW
|
|
106
|
+
flags = File::WRONLY | File::APPEND | File::CREAT | File::NOFOLLOW if deadline && create
|
|
107
|
+
flags |= File::NONBLOCK unless deadline.nil?
|
|
108
|
+
handles = []
|
|
109
|
+
event_ids.sort.each do |event_id|
|
|
110
|
+
lock = File.open(lock_path(deliveries_dir, event_id), flags)
|
|
111
|
+
handles << lock
|
|
112
|
+
raise ArgumentError, "delivery event lock is not a regular file" unless lock.stat.file?
|
|
113
|
+
acquire_lock!(lock, File::LOCK_EX, deadline)
|
|
114
|
+
end
|
|
115
|
+
yield
|
|
116
|
+
ensure
|
|
117
|
+
handles&.reverse_each do |lock|
|
|
118
|
+
lock.flock(File::LOCK_UN)
|
|
119
|
+
lock.close
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def acquire_lock!(lock, mode, deadline)
|
|
124
|
+
admission_deadline!(deadline) unless deadline.nil?
|
|
125
|
+
unless lock.flock(mode | (deadline ? File::LOCK_NB : 0))
|
|
126
|
+
raise LockUnavailable, "delivery exclusion is busy"
|
|
127
|
+
end
|
|
128
|
+
admission_deadline!(deadline) unless deadline.nil?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def admission_deadline!(deadline)
|
|
132
|
+
unless (deadline.is_a?(Integer) || deadline.is_a?(Float)) && deadline.finite?
|
|
133
|
+
raise ArgumentError, "delivery deadline must be finite"
|
|
134
|
+
end
|
|
135
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
136
|
+
raise LockUnavailable, "delivery admission deadline expired"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
71
140
|
def path_for(deliveries_dir, event_id)
|
|
72
141
|
File.join(deliveries_dir, "#{event_id}.json")
|
|
73
142
|
end
|
|
@@ -115,6 +184,12 @@ module Ace
|
|
|
115
184
|
# archived event just reports its archive path.
|
|
116
185
|
# @return [String] the archive path the record lives at
|
|
117
186
|
def archive(deliveries_dir, event_id)
|
|
187
|
+
with_inventory_lock(deliveries_dir, exclusive: false, create: true) do
|
|
188
|
+
archive_held(deliveries_dir, event_id)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def archive_held(deliveries_dir, event_id)
|
|
118
193
|
dest = File.join(archive_dir(deliveries_dir), "#{event_id}.json")
|
|
119
194
|
return dest if !File.exist?(path_for(deliveries_dir, event_id)) && File.exist?(dest)
|
|
120
195
|
|
|
@@ -147,6 +222,7 @@ module Ace
|
|
|
147
222
|
rescue JSON::ParserError, ArgumentError
|
|
148
223
|
nil
|
|
149
224
|
end
|
|
225
|
+
private_class_method :save_held, :archive_held, :with_event_locks_held
|
|
150
226
|
end
|
|
151
227
|
end
|
|
152
228
|
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "ace/runtime/errors"
|
|
3
|
+
|
|
4
|
+
module Ace
|
|
5
|
+
module Herdr
|
|
6
|
+
module Molecules
|
|
7
|
+
# Closed N1 producer value. Native endpoint selection and kernel capture
|
|
8
|
+
# belong to ProtectedNativeControl; this value never discovers a target.
|
|
9
|
+
module GuardedNativeOrigin
|
|
10
|
+
UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
|
|
11
|
+
TERMINAL = /\Aterm_[0-9a-f]{2,48}\z/
|
|
12
|
+
CHILD_FIELDS = %w[gid groups host parent_pid pid started_at uid].freeze
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def verify!(value, terminal_id:, child:)
|
|
16
|
+
unless value.is_a?(Hash) && value.keys.all? { |key| key.is_a?(String) } && value.keys.sort == %w[child runtime_incarnation terminal_id] &&
|
|
17
|
+
value["terminal_id"] == terminal_id && terminal_id.is_a?(String) && terminal_id.ascii_only? && TERMINAL.match?(terminal_id) &&
|
|
18
|
+
value["runtime_incarnation"].is_a?(String) && value["runtime_incarnation"].ascii_only? && UUID.match?(value["runtime_incarnation"])
|
|
19
|
+
unavailable!
|
|
20
|
+
end
|
|
21
|
+
identity = value.fetch("child")
|
|
22
|
+
unless identity.is_a?(Hash) && identity.keys.all? { |key| key.is_a?(String) } && identity.keys.sort == CHILD_FIELDS && identity == child &&
|
|
23
|
+
%w[pid parent_pid].all? { |key| uint32?(identity[key]) && identity[key].positive? } &&
|
|
24
|
+
%w[uid gid].all? { |key| uint32?(identity[key]) } && identity["groups"].is_a?(Array) &&
|
|
25
|
+
identity["groups"].size <= 256 && identity["groups"].all? { |group| uint32?(group) } &&
|
|
26
|
+
identity["groups"] == identity["groups"].sort.uniq && valid_birth?(identity["started_at"]) &&
|
|
27
|
+
identity["host"].is_a?(String) && identity["host"].valid_encoding? && (identity["host"].encoding == Encoding::UTF_8 || identity["host"].ascii_only?) &&
|
|
28
|
+
identity["host"].bytesize.between?(1, 255) && !identity["host"].match?(/\p{Cc}/)
|
|
29
|
+
unavailable!
|
|
30
|
+
end
|
|
31
|
+
immutable(value)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def uint32?(value) = value.is_a?(Integer) && value.between?(0, 0xffff_ffff)
|
|
35
|
+
|
|
36
|
+
def valid_birth?(value)
|
|
37
|
+
return false unless value.is_a?(String) && value.ascii_only?
|
|
38
|
+
parts = value.split(":", -1)
|
|
39
|
+
parts.size == 3 && parts[0] == "linux" && UUID.match?(parts[1]) &&
|
|
40
|
+
parts[2].match?(/\A[0-9]+\z/) && parts[2].to_i.between?(1, 0xffff_ffff_ffff_ffff)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def immutable(value)
|
|
44
|
+
case value
|
|
45
|
+
when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
|
|
46
|
+
when Array then value.map { |item| immutable(item) }.freeze
|
|
47
|
+
when String then value.dup.freeze
|
|
48
|
+
else value.freeze
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def unavailable!
|
|
53
|
+
raise Ace::Runtime::RuntimeUnavailableError, "original native guarded origin is unavailable"
|
|
54
|
+
end
|
|
55
|
+
private_class_method :uint32?, :valid_birth?, :immutable, :unavailable!
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../errors"
|
|
3
4
|
require "open3"
|
|
4
5
|
require "json"
|
|
5
6
|
require "timeout"
|
|
7
|
+
require_relative "bounded_process"
|
|
6
8
|
|
|
7
9
|
module Ace
|
|
8
10
|
module Herdr
|
|
@@ -16,8 +18,9 @@ module Ace
|
|
|
16
18
|
WAKE_OUTPUT_LIMIT = 65_536
|
|
17
19
|
DEFAULT_PROBE_TIMEOUT_S = 60
|
|
18
20
|
|
|
19
|
-
def initialize(binary: DEFAULT_BINARY)
|
|
21
|
+
def initialize(binary: DEFAULT_BINARY, process: BoundedProcess)
|
|
20
22
|
@binary = binary
|
|
23
|
+
@process = process
|
|
21
24
|
end
|
|
22
25
|
|
|
23
26
|
# Probe the agent living in a pane (raises AgentNotFoundError when none)
|
|
@@ -30,7 +33,7 @@ module Ace
|
|
|
30
33
|
# killed at the deadline and the timeout classifies as a retryable
|
|
31
34
|
# pre-submission failure.
|
|
32
35
|
def pane_get_bounded(pane, timeout_s: DEFAULT_PROBE_TIMEOUT_S)
|
|
33
|
-
result =
|
|
36
|
+
result = @process.call([@binary, "pane", "get", pane], stdin_data: "",
|
|
34
37
|
timeout_s: timeout_s, output_limit: WAKE_OUTPUT_LIMIT)
|
|
35
38
|
execution = ExecutionResult.new(
|
|
36
39
|
stdout: result.stdout.strip, stderr: result.stderr.strip,
|
|
@@ -65,7 +68,7 @@ module Ace
|
|
|
65
68
|
# keep the inbox event lock through Open3.capture3 cleanup.
|
|
66
69
|
def agent_prompt_bounded(pane:, text:, timeout_ms:)
|
|
67
70
|
cmd = [@binary, "agent", "prompt", pane, text]
|
|
68
|
-
result =
|
|
71
|
+
result = @process.call(cmd, stdin_data: "",
|
|
69
72
|
timeout_s: timeout_ms / 1000.0, output_limit: WAKE_OUTPUT_LIMIT)
|
|
70
73
|
execution = ExecutionResult.new(
|
|
71
74
|
stdout: result.stdout.strip, stderr: result.stderr.strip,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative "inbox_context_store"
|
|
3
|
+
|
|
4
|
+
module Ace
|
|
5
|
+
module Herdr
|
|
6
|
+
module Molecules
|
|
7
|
+
module InboxCliInput
|
|
8
|
+
module_function
|
|
9
|
+
def read(path, limit:)
|
|
10
|
+
unless path.is_a?(String) && !path.empty? && !path.include?("\0")
|
|
11
|
+
raise ValidationError, "inbox input path differs"
|
|
12
|
+
end
|
|
13
|
+
before = File.lstat(path)
|
|
14
|
+
raise ValidationError, "inbox input is not a bounded regular file" unless before.file? && !before.symlink? && before.size.between?(1, limit)
|
|
15
|
+
snapshot = ->(stat) { [stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime] }
|
|
16
|
+
File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |handle|
|
|
17
|
+
raise ValidationError, "inbox input handle changed" unless snapshot.call(before) == snapshot.call(handle.stat)
|
|
18
|
+
bytes = handle.read(before.size + 1)
|
|
19
|
+
unless bytes && bytes.bytesize == before.size && snapshot.call(before) == snapshot.call(handle.stat) &&
|
|
20
|
+
snapshot.call(before) == snapshot.call(File.lstat(path))
|
|
21
|
+
raise ValidationError, "inbox input changed"
|
|
22
|
+
end
|
|
23
|
+
bytes.freeze
|
|
24
|
+
end
|
|
25
|
+
rescue SystemCallError, IOError
|
|
26
|
+
raise ValidationError, "inbox input is unavailable"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def reverse(path)
|
|
30
|
+
value = InboxContextStore.decode(read(path, limit: 16_384), limit: 16_384)
|
|
31
|
+
InboxDirectEffectBinding.reverse!(value)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|