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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/herdr/config.yml +0 -4
  3. data/CHANGELOG.md +30 -0
  4. data/docs/usage.md +65 -32
  5. data/lib/ace/herdr/cli/commands/inbox.rb +81 -31
  6. data/lib/ace/herdr/cli/commands/native_source.rb +56 -0
  7. data/lib/ace/herdr/cli/commands/support.rb +2 -6
  8. data/lib/ace/herdr/cli.rb +3 -0
  9. data/lib/ace/herdr/errors.rb +23 -0
  10. data/lib/ace/herdr/molecules/bounded_process.rb +266 -51
  11. data/lib/ace/herdr/molecules/delivery_record_store.rb +81 -5
  12. data/lib/ace/herdr/molecules/guarded_native_origin.rb +59 -0
  13. data/lib/ace/herdr/molecules/herdr_executor.rb +6 -3
  14. data/lib/ace/herdr/molecules/inbox_cli_input.rb +36 -0
  15. data/lib/ace/herdr/molecules/inbox_context_client.rb +88 -0
  16. data/lib/ace/herdr/molecules/inbox_context_completion_client.rb +119 -0
  17. data/lib/ace/herdr/molecules/inbox_context_key.rb +63 -0
  18. data/lib/ace/herdr/molecules/inbox_context_lifetime.rb +122 -0
  19. data/lib/ace/herdr/molecules/inbox_context_native_process.rb +92 -0
  20. data/lib/ace/herdr/molecules/inbox_context_service_configuration.rb +183 -0
  21. data/lib/ace/herdr/molecules/inbox_context_store.rb +159 -0
  22. data/lib/ace/herdr/molecules/inbox_context_wire.rb +76 -0
  23. data/lib/ace/herdr/molecules/inbox_direct_effect_binding.rb +92 -0
  24. data/lib/ace/herdr/molecules/inbox_direct_result.rb +65 -0
  25. data/lib/ace/herdr/molecules/native_queue_executor.rb +10 -21
  26. data/lib/ace/herdr/molecules/native_source.rb +189 -0
  27. data/lib/ace/herdr/molecules/protected_inbox_selection.rb +126 -0
  28. data/lib/ace/herdr/molecules/protected_native_control.rb +259 -0
  29. data/lib/ace/herdr/native_source/HERDR-LICENSE +201 -0
  30. data/lib/ace/herdr/native_source/guarded-prompt.patch +3493 -0
  31. data/lib/ace/herdr/native_source/selection.json +24 -0
  32. data/lib/ace/herdr/organisms/deliverer.rb +2 -3
  33. data/lib/ace/herdr/organisms/inbox.rb +384 -137
  34. data/lib/ace/herdr/organisms/inbox_context_direct_effects.rb +285 -0
  35. data/lib/ace/herdr/organisms/inbox_context_effects.rb +30 -0
  36. data/lib/ace/herdr/organisms/inbox_context_listener.rb +151 -0
  37. data/lib/ace/herdr/organisms/inbox_context_owner.rb +550 -0
  38. data/lib/ace/herdr/organisms/inbox_context_server.rb +70 -0
  39. data/lib/ace/herdr/organisms/inbox_context_service.rb +107 -0
  40. data/lib/ace/herdr/organisms/inbox_guarded_wake.rb +117 -0
  41. data/lib/ace/herdr/organisms/native_source_builder.rb +183 -0
  42. data/lib/ace/herdr/organisms/runtime_adapter.rb +30 -1
  43. data/lib/ace/herdr/version.rb +1 -1
  44. data/lib/ace/herdr.rb +5 -28
  45. metadata +69 -8
@@ -1,15 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
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, so the caller never blocks in cleanup past the timeout the
12
- # way an Open3.capture3 + Timeout.timeout combination can.
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] per-stream retained byte cap
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
- Open3.popen3(*argv, pgroup: true) do |stdin, stdout, stderr, waiter|
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
- run_loop(stdin, stdout, stderr, waiter,
40
- stdin_data: stdin_data, timeout_s: timeout_s, output_limit: output_limit)
41
- rescue SystemCallError => e
42
- # The child was already spawned: an I/O failure now cannot be
43
- # rewound into a pre-launch classification, and the child must
44
- # not outlive the failure inside popen3's cleanup wait.
45
- kill_group(waiter)
46
- waiter.join
47
- raise PostLaunchError, e.message
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 run_loop(stdin, stdout, stderr, waiter, stdin_data:, timeout_s:, output_limit:)
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
- buffers = {stdout => +"", stderr => +""}
62
- streams = [stdout, stderr]
63
- oversized = false
64
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_s
189
+ group_cleaned = false
65
190
 
66
- until streams.empty? && stdin_closed && waiter.join(0)
67
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
68
- if remaining <= 0
191
+ loop do
192
+ if streams.empty? && stdin_closed
193
+ if cleanup_group && !group_cleaned && waiter.exited?
69
194
  kill_group(waiter)
70
- raise Timeout::Error, "process did not finish within #{timeout_s}s"
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
- ready = IO.select(streams, stdin_closed ? [] : [stdin], nil, [remaining, 0.02].min)
74
- next unless ready
205
+ ready = IO.select(streams, stdin_closed ? [] : [stdin], nil, [remaining, 0.02].min)
206
+ next unless ready
75
207
 
76
- ready.first.each do |io|
208
+ ready.first.each do |io|
209
+ begin
77
210
  chunk = io.read_nonblock(4096, exception: false)
78
- if chunk.nil?
79
- io.close
80
- streams.delete(io)
81
- elsif chunk != :wait_readable
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
- next if stdin_closed || !ready[1].to_a.include?(stdin)
228
+ next if stdin_closed || !ready[1].to_a.include?(stdin)
90
229
 
91
- begin
92
- # Nonblocking with partial writes: a child that stops reading
93
- # must never trap us inside one large stdin.write; the loop
94
- # re-checks the deadline between write attempts.
95
- written = stdin.write_nonblock(payload.byteslice(sent, payload.bytesize - sent),
96
- exception: false)
97
- sent += written if written != :wait_writable
98
- rescue Errno::EPIPE, IOError
99
- sent = payload.bytesize
100
- end
101
- if sent >= payload.bytesize
102
- stdin.close
103
- stdin_closed = true
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
- FileUtils.mkdir_p(deliveries_dir)
61
- File.open(lock_path(deliveries_dir, event_id), "a") do |lock|
62
- lock.flock(File::LOCK_EX)
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
- return yield
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 = BoundedProcess.call([@binary, "pane", "get", pane], stdin_data: "",
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 = BoundedProcess.call(cmd, stdin_data: "",
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