libtmux 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,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fcntl"
4
+ require "libtmux/server"
5
+ require "libtmux/child"
6
+ require "libtmux/socket_readiness"
7
+
8
+ module LibTmux
9
+ class Server
10
+ # Starts a foreground daemon on a new private endpoint. Close owns its exit.
11
+ # No session is created; the default config is empty. Startup blocks.
12
+ def self.start(**options, &block)
13
+ Internal.const_get(:OwnedServer, false).open(**options, &block)
14
+ end
15
+
16
+ def owned?
17
+ false
18
+ end
19
+ end
20
+
21
+ module Internal
22
+ class OwnedDaemon
23
+ attr_reader :endpoint
24
+
25
+ def initialize(executable:, config:, timeout:, cancel:)
26
+ unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
27
+ raise ArgumentError, "startup timeout must be positive and finite"
28
+ end
29
+ if cancel && (!cancel.respond_to?(:reader) || !cancel.respond_to?(:cancelled?))
30
+ raise ArgumentError, "cancel must provide a cancellation reader and state"
31
+ end
32
+ if config && (!config.is_a?(String) || config.empty? || config.include?("\0"))
33
+ raise ArgumentError, "config must be a nonempty filename without NUL"
34
+ end
35
+ config = config ? File.expand_path(config) : File::NULL
36
+ raise ArgumentError, "config must be a readable regular file" unless File.file?(config) && File.readable?(config) || config == File::NULL
37
+
38
+ @owner_pid = Process.pid
39
+ @mutex = Mutex.new
40
+ @closed = false
41
+ failure = nil
42
+ deadline = clock + timeout
43
+ begin
44
+ Thread.handle_interrupt(Exception => :never) do
45
+ begin
46
+ check_cancel(cancel)
47
+ @directory = Dir.mktmpdir("libtmux-ruby-server-")
48
+ @endpoint = Endpoint.new(socket_path: File.join(@directory, "socket"), executable: executable)
49
+ if @endpoint.socket_path.bytesize > 103
50
+ raise UnsupportedFeatureError.new("owned Unix socket path exceeds the platform limit", phase: :startup)
51
+ end
52
+ @readiness = SocketReadiness.new(@directory)
53
+ @child = OwnedChild.new
54
+ pid = nil
55
+ begin
56
+ check_cancel(cancel)
57
+ check_deadline(deadline)
58
+ pid = Process.spawn({"TMUX" => nil, "TMUX_PANE" => nil},
59
+ @endpoint.executable, "-u", "-D", *@readiness.arguments, "-S", @endpoint.socket_path, "-f", config,
60
+ in: File::NULL, out: File::NULL, err: File::NULL, close_others: true, **@readiness.spawn_options)
61
+ rescue SystemCallError, IOError => error
62
+ raise TransportError.new("owned daemon could not start (#{error.class})", phase: :spawn), cause: nil
63
+ ensure
64
+ @child.spawned(pid)
65
+ end
66
+ Thread.handle_interrupt(Exception => :immediate) { await_ready(deadline, cancel) }
67
+ @readiness.close
68
+ @readiness.remove_files(@child.pid)
69
+ rescue Exception => error
70
+ failure = error
71
+ begin
72
+ close
73
+ rescue Exception => cleanup
74
+ attach_cleanup(failure, cleanup)
75
+ end
76
+ end
77
+ end
78
+ rescue Exception => deferred
79
+ failure ||= deferred
80
+ end
81
+ if failure
82
+ begin
83
+ Thread.handle_interrupt(Exception => :never) { close }
84
+ rescue Exception => cleanup
85
+ attach_cleanup(failure, cleanup)
86
+ end
87
+ raise failure
88
+ end
89
+ end
90
+
91
+ def close
92
+ if Process.pid != @owner_pid
93
+ @child&.detach
94
+ @readiness&.close
95
+ return nil
96
+ end
97
+ Thread.handle_interrupt(Exception => :never) do
98
+ @mutex.synchronize do
99
+ return nil if @closed
100
+
101
+ errors = []
102
+ deadline = clock + 0.5
103
+ attempt(errors, "readiness close") { @readiness&.close }
104
+ if @child
105
+ attempt(errors, "daemon retirement") do
106
+ begin
107
+ unless @child.observed? || @child.complete?
108
+ @child.signal("TERM")
109
+ @child.wait_observed([0.05, deadline - clock].min.clamp(0, 0.05))
110
+ @child.signal("KILL") unless @child.observed? || @child.complete?
111
+ end
112
+ ensure
113
+ @child.finish_signalling
114
+ end
115
+ unless @child.join((deadline - clock).clamp(0, 0.5))
116
+ raise DeadlineExceeded.new("owned daemon has not retired; retry close", phase: :retire, pid: @child.pid)
117
+ end
118
+ end
119
+ attempt(errors, "observer close") { @child.close } if @child.complete?
120
+ end
121
+ if !@child || @child.complete?
122
+ attempt(errors, "startup log removal") { @readiness&.remove_files(@child&.pid) }
123
+ attempt(errors, "socket removal") do
124
+ File.unlink(@endpoint.socket_path) if @endpoint && File.exist?(@endpoint.socket_path)
125
+ end
126
+ attempt(errors, "owned directory removal") do
127
+ Dir.rmdir(@directory) if @directory && File.exist?(@directory)
128
+ end
129
+ end
130
+ @closed = errors.empty?
131
+ if @child&.complete? && !@observer_fault_reported
132
+ [@child.retirement_error, @child.observation_error].compact.each do |error|
133
+ errors << "owned child observation or retirement failed (#{error.class})"
134
+ end
135
+ @observer_fault_reported = true
136
+ end
137
+ unless errors.empty?
138
+ raise TransportError.new("owned daemon cleanup failed", phase: :retire,
139
+ pid: @child&.pid, delivery: @child&.pid ? :possibly_sent : :not_sent, cleanup_errors: errors)
140
+ end
141
+ end
142
+ end
143
+ nil
144
+ end
145
+
146
+ private
147
+
148
+ def await_ready(deadline, cancel)
149
+ loop do
150
+ if @child.observed? || @child.observation_error || @child.complete?
151
+ raise TransportError.new("owned tmux daemon exited before becoming ready", phase: :startup,
152
+ pid: @child.pid, delivery: :possibly_sent)
153
+ end
154
+ if @readiness.ready?(@child)
155
+ check_deadline(deadline)
156
+ return
157
+ end
158
+
159
+ check_cancel(cancel)
160
+ check_deadline(deadline)
161
+ readers = [@readiness.reader, @child.reader]
162
+ readers << cancel.reader if cancel
163
+ IO.select(readers, nil, nil, [deadline - clock, 0].max)
164
+ end
165
+ rescue IOError, SystemCallError
166
+ raise TransportError.new("owned daemon readiness failed", phase: :startup,
167
+ pid: @child.pid, delivery: :possibly_sent), cause: nil
168
+ end
169
+
170
+ def check_cancel(cancel)
171
+ return unless cancel&.cancelled?
172
+
173
+ raise Cancelled.new("owned daemon startup cancelled", phase: :startup,
174
+ delivery: @child&.pid ? :possibly_sent : :not_sent, pid: @child&.pid)
175
+ end
176
+
177
+ def check_deadline(deadline)
178
+ return if clock < deadline
179
+
180
+ raise DeadlineExceeded.new("owned daemon startup exceeded deadline", phase: :startup,
181
+ delivery: @child&.pid ? :possibly_sent : :not_sent, pid: @child&.pid)
182
+ end
183
+
184
+ def clock
185
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
186
+ end
187
+
188
+ def attempt(errors, operation)
189
+ yield
190
+ rescue Exception => error
191
+ errors << "#{operation} failed (#{error.class})"
192
+ errors.concat(error.cleanup_errors) if error.is_a?(Error)
193
+ end
194
+
195
+ def attach_cleanup(failure, cleanup)
196
+ if failure.is_a?(Error)
197
+ failure.__send__(:attach_cleanup_errors, ["owned daemon close failed (#{cleanup.class})"])
198
+ end
199
+ end
200
+ end
201
+
202
+ class OwnedServer < Server
203
+ def initialize(executable: "tmux", config: nil, timeout: 5.0, cancel: nil, **options)
204
+ begin
205
+ Thread.handle_interrupt(Exception => :never) do
206
+ @daemon = OwnedDaemon.new(executable: executable, config: config, timeout: timeout, cancel: cancel)
207
+ super(endpoint: @daemon.endpoint, **options)
208
+ @binding_ready = true
209
+ Thread.handle_interrupt(Exception => :immediate) { nil }
210
+ end
211
+ rescue Exception => failure
212
+ begin
213
+ Thread.handle_interrupt(Exception => :never) { close }
214
+ rescue Exception => cleanup
215
+ failure.__send__(:attach_cleanup_errors, ["owned daemon close failed (#{cleanup.class})"]) if failure.is_a?(Error)
216
+ end
217
+ raise failure
218
+ end
219
+ end
220
+
221
+ def owned?
222
+ true
223
+ end
224
+
225
+ def close
226
+ failure = nil
227
+ begin
228
+ Thread.handle_interrupt(Exception => :never) do
229
+ begin
230
+ super if @binding_ready
231
+ rescue Exception => error
232
+ failure = error
233
+ ensure
234
+ begin
235
+ @daemon&.close
236
+ rescue Exception => cleanup
237
+ failure.__send__(:attach_cleanup_errors, ["owned daemon close failed (#{cleanup.class})"]) if failure.is_a?(Error)
238
+ failure ||= cleanup
239
+ end
240
+ end
241
+ end
242
+ rescue Exception => deferred
243
+ failure ||= deferred
244
+ end
245
+ raise failure if failure
246
+
247
+ nil
248
+ end
249
+ end
250
+ private_constant :SocketReadiness, :OwnedDaemon, :OwnedServer
251
+ end
252
+ end
@@ -0,0 +1,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "libtmux/errors"
4
+ require "libtmux/child"
5
+
6
+ module LibTmux
7
+ class CommandResult
8
+ attr_reader :stdout, :stderr, :status, :elapsed_seconds, :pid, :argv
9
+
10
+ def initialize(stdout:, stderr:, status:, elapsed_seconds:, pid:, argv:)
11
+ @stdout = stdout.b.freeze
12
+ @stderr = stderr.b.freeze
13
+ @status = status
14
+ @elapsed_seconds = elapsed_seconds
15
+ @pid = pid
16
+ @argv = argv.map { |argument| argument.dup.freeze }.freeze
17
+ freeze
18
+ end
19
+
20
+ def success?
21
+ status.success?
22
+ end
23
+
24
+ def delivery
25
+ :observed
26
+ end
27
+
28
+ def text(invalid: :strict)
29
+ raise ArgumentError, "invalid text policy" unless [:strict, :replace].include?(invalid)
30
+
31
+ text = stdout.dup.force_encoding(Encoding::UTF_8)
32
+ return text.scrub if invalid == :replace
33
+ return text if text.valid_encoding?
34
+
35
+ raise FieldDecodeError.new("command stdout is not valid UTF-8", delivery: delivery, phase: :decode, pid: pid)
36
+ end
37
+
38
+ def inspect
39
+ "#<#{self.class} pid=#{pid} exitstatus=#{status.exitstatus} stdout_bytes=#{stdout.bytesize} stderr_bytes=#{stderr.bytesize}>"
40
+ end
41
+ end
42
+
43
+ class Cancellation
44
+ attr_reader :reader
45
+
46
+ def initialize
47
+ @reader, @writer = IO.pipe
48
+ @mutex = Mutex.new
49
+ @cancelled = false
50
+ @creator_pid = Process.pid
51
+ end
52
+
53
+ def cancel
54
+ ensure_owner
55
+ @mutex.synchronize do
56
+ return if @cancelled
57
+
58
+ @cancelled = true
59
+ @writer.write_nonblock("x", exception: false)
60
+ end
61
+ end
62
+
63
+ def cancelled?
64
+ ensure_owner
65
+ @mutex.synchronize { @cancelled }
66
+ end
67
+
68
+ def close
69
+ return detach unless @creator_pid == Process.pid
70
+
71
+ cancel
72
+ @mutex.synchronize do
73
+ [@reader, @writer].each { |io| io.close unless io.closed? }
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def detach
80
+ raise ArgumentError, "only a forked child may detach this token" if @creator_pid == Process.pid
81
+
82
+ [@reader, @writer].each { |io| io.close unless io.closed? }
83
+ nil
84
+ end
85
+
86
+ def ensure_owner
87
+ raise ClosedError.new("cancellation token belongs to another process", phase: :admission) unless @creator_pid == Process.pid
88
+ end
89
+ end
90
+
91
+ module Internal
92
+ Cancellation = LibTmux::Cancellation
93
+
94
+ class ProcessExecutor
95
+ def initialize(stdout_limit: 1 << 20, stderr_limit: 1 << 18, input_limit: 1 << 20, argv_limit: 1 << 18, cleanup_timeout: 0.5, drain_timeout: 0.5)
96
+ [stdout_limit, stderr_limit, input_limit, argv_limit].each do |limit|
97
+ raise ArgumentError, "byte limits must be nonnegative integers" unless limit.is_a?(Integer) && limit >= 0
98
+ end
99
+ [cleanup_timeout, drain_timeout].each do |timeout|
100
+ raise ArgumentError, "cleanup and drain deadlines must be positive and finite" unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
101
+ end
102
+ @limits = {stdout: stdout_limit, stderr: stderr_limit, input: input_limit, argv: argv_limit}.freeze
103
+ @cleanup_timeout = cleanup_timeout
104
+ @drain_timeout = drain_timeout
105
+ end
106
+
107
+ def run(argv, input: "".b, env: {}, timeout: 5.0, cancel: nil)
108
+ Execution.new(argv, input, env, timeout, cancel, @limits, @cleanup_timeout, @drain_timeout).call
109
+ end
110
+
111
+ class Execution
112
+ CHUNK_BYTES = 16_384
113
+
114
+ def initialize(argv, input, env, timeout, cancel, limits, cleanup_timeout, drain_timeout)
115
+ @started = monotonic
116
+ unless argv.is_a?(Array) && !argv.empty? && argv.all? { |argument| argument.is_a?(String) && !argument.include?("\0") }
117
+ raise ArgumentError, "argv must contain strings without NUL"
118
+ end
119
+ raise ArgumentError, "input must be a String" unless input.is_a?(String)
120
+ raise ArgumentError, "timeout must be finite" unless timeout.is_a?(Numeric) && timeout.finite?
121
+ if input.bytesize > limits.fetch(:input) || argv.sum { |argument| argument.bytesize + 1 } > limits.fetch(:argv)
122
+ raise CapacityError.new("command input exceeded its byte limit", delivery: :not_sent, phase: :admission)
123
+ end
124
+
125
+ @argv = argv.map { |argument| argument.dup.freeze }.freeze
126
+ @input = input.b.freeze
127
+ @env = env.merge("TMUX" => nil, "TMUX_PANE" => nil)
128
+ @deadline = @started + timeout
129
+ @cancel = cancel
130
+ @limits = limits
131
+ @cleanup_timeout = cleanup_timeout
132
+ @drain_timeout = drain_timeout
133
+ @process_wait = ProcessWait.new
134
+ @owned = []
135
+ @buffers = {stdout: +"".b, stderr: +"".b}
136
+ @offset = 0
137
+ end
138
+
139
+ def call
140
+ error = nil
141
+ result = nil
142
+ begin
143
+ Thread.handle_interrupt(Exception => :never) do
144
+ begin
145
+ check_deadline(:admission)
146
+ check_cancel(:admission)
147
+ spawn
148
+ Thread.handle_interrupt(Exception => :immediate) { result = communicate }
149
+ rescue Exception => failure
150
+ error = failure
151
+ ensure
152
+ cleanup_errors = retire
153
+ if error.is_a?(Error)
154
+ error.send(:attach_cleanup_errors, cleanup_errors)
155
+ elsif error.nil? && !cleanup_errors.empty?
156
+ error = TransportError.new("command cleanup failed", **details(:retire), cleanup_errors: cleanup_errors)
157
+ end
158
+ end
159
+ end
160
+ rescue Exception => deferred
161
+ error ||= deferred
162
+ end
163
+ raise error, cause: nil if error
164
+
165
+ result
166
+ end
167
+
168
+ private
169
+
170
+ def monotonic
171
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
172
+ end
173
+
174
+ def pipe
175
+ pair = IO.pipe
176
+ pair.each(&:binmode)
177
+ @owned.concat(pair)
178
+ pair
179
+ end
180
+
181
+ def spawn
182
+ child_input, @input_writer = pipe
183
+ @output_reader, child_output = pipe
184
+ @error_reader, child_error = pipe
185
+ @child = OwnedChild.new(@process_wait)
186
+ @exit_reader = @child.reader
187
+ @owned << @exit_reader
188
+ begin
189
+ @pid = Process.spawn(@env, [@argv.first, @argv.first], *@argv.drop(1),
190
+ in: child_input, out: child_output, err: child_error, close_others: true)
191
+ ensure
192
+ @child.spawned(@pid)
193
+ end
194
+ [child_input, child_output, child_error].each(&:close)
195
+ @input_writer.close if @input.empty?
196
+ rescue SystemCallError, IOError => error
197
+ raise TransportError.new("could not start command (#{error.class})", **details(:spawn)), cause: nil
198
+ end
199
+
200
+ def communicate
201
+ reading = {@output_reader => :stdout, @error_reader => :stderr}
202
+ loop do
203
+ return result if @status && reading.empty?
204
+
205
+ @drain_deadline ||= monotonic + @drain_timeout if @child.observed?
206
+ phase = @drain_deadline ? :drain : (@input_writer.closed? ? :read : :write)
207
+ check_cancel(phase) unless @status
208
+ check_deadline(phase)
209
+ readers = reading.keys
210
+ readers << @exit_reader unless @status
211
+ readers << @cancel.reader if @cancel && !@status
212
+ writers = @input_writer.closed? ? [] : [@input_writer]
213
+ remaining = [@deadline, @drain_deadline || @deadline].min - monotonic
214
+ ready = IO.select(readers, writers, nil, [remaining, 0].max)
215
+ next unless ready
216
+
217
+ if ready.first.include?(@exit_reader)
218
+ @exit_reader.read_nonblock(CHUNK_BYTES, exception: false)
219
+ raise @child.observation_error if @child.observation_error
220
+
221
+ @child.finish_signalling
222
+ if @child.complete?
223
+ raise @child.retirement_error if @child.retirement_error
224
+
225
+ @status = @child.status
226
+ @exit_reader.close
227
+ @input_writer.close unless @input_writer.closed?
228
+ @drain_deadline ||= monotonic + @drain_timeout
229
+ end
230
+ end
231
+ ready.first.each { |io| read_output(io, reading) if reading.key?(io) }
232
+ write_input if ready[1].include?(@input_writer) && !@input_writer.closed?
233
+ end
234
+ rescue SystemCallError, IOError => error
235
+ check_cancel(:read) unless @status
236
+ raise TransportError.new("command I/O failed (#{error.class})", **details(:read)), cause: nil
237
+ end
238
+
239
+ def read_output(io, reading)
240
+ stream = reading.fetch(io)
241
+ buffer = @buffers.fetch(stream)
242
+ available = @limits.fetch(stream) - buffer.bytesize
243
+ bytes = io.read_nonblock([CHUNK_BYTES, available + 1].min, exception: false)
244
+ if bytes.nil?
245
+ reading.delete(io)
246
+ io.close
247
+ elsif bytes.is_a?(String)
248
+ raise CapacityError.new("command #{stream} exceeded its byte limit", **details(:read)) if bytes.bytesize > available
249
+
250
+ buffer << bytes
251
+ end
252
+ end
253
+
254
+ def write_input
255
+ bytes = @input.byteslice(@offset, CHUNK_BYTES)
256
+ written = @input_writer.write_nonblock(bytes, exception: false)
257
+ @offset += written if written.is_a?(Integer)
258
+ @input_writer.close if @offset == @input.bytesize
259
+ rescue Errno::EPIPE
260
+ @input_writer.close
261
+ end
262
+
263
+ def check_cancel(phase)
264
+ if @cancel&.cancelled? && !@child&.observed?
265
+ raise Cancelled.new("command was cancelled", **details(phase))
266
+ end
267
+ end
268
+
269
+ def check_deadline(phase)
270
+ deadline = [@deadline, @drain_deadline || @deadline].min
271
+ raise DeadlineExceeded.new("command deadline exceeded", **details(phase)) if monotonic >= deadline
272
+ end
273
+
274
+ def details(phase)
275
+ {delivery: @status ? :observed : (@pid ? :possibly_sent : :not_sent), phase: phase, pid: @pid}
276
+ end
277
+
278
+ def result
279
+ CommandResult.new(**@buffers, status: @status, elapsed_seconds: monotonic - @started, pid: @pid, argv: @argv)
280
+ end
281
+
282
+ def retire
283
+ errors = []
284
+ @owned.each do |io|
285
+ io.close unless io.closed?
286
+ rescue IOError, SystemCallError => error
287
+ errors << "descriptor cleanup failed (#{error.class})"
288
+ end
289
+ return errors unless @child
290
+
291
+ deadline = monotonic + @cleanup_timeout
292
+ unless @pid
293
+ errors << "command waiter did not finish" unless @child.join(@cleanup_timeout)
294
+ return errors
295
+ end
296
+ unless @child.observed? || @child.observation_error.is_a?(Errno::ECHILD)
297
+ signal("TERM", errors)
298
+ # Reserve the cleanup budget for reaping; timer grace can overrun it.
299
+ signal("KILL", errors) unless @child.observed?
300
+ end
301
+ @child.finish_signalling
302
+ if @child.join([deadline - monotonic, 0].max)
303
+ @status ||= @child.status
304
+ else
305
+ errors << "owned client cleanup remains pending after its deadline"
306
+ end
307
+ errors << "command exit observation failed (#{@child.observation_error.class})" if @child.observation_error
308
+ errors << "command fallback reap failed (#{@child.retirement_error.class})" if @child.retirement_error
309
+ errors
310
+ rescue StandardError => error
311
+ errors << "command waiter failed (#{error.class})"
312
+ errors
313
+ end
314
+
315
+ def signal(name, errors)
316
+ @child.signal(name)
317
+ rescue Errno::ESRCH
318
+ nil
319
+ rescue SystemCallError => error
320
+ errors << "client termination failed (#{error.class})"
321
+ end
322
+ end
323
+
324
+ private_constant :Execution
325
+ end
326
+ end
327
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+ require "libtmux/errors"
5
+
6
+ module LibTmux
7
+ module Internal
8
+ class ProcessWait
9
+ P_PID = 1
10
+ WNOHANG = 0x00000001
11
+ WEXITED = 0x00000004
12
+ private_constant :P_PID, :WNOHANG, :WEXITED
13
+
14
+ def initialize
15
+ unless Fiddle::SIZEOF_INT == 4
16
+ raise UnsupportedFeatureError.new("native process waiting requires a 32-bit C int", phase: :admission)
17
+ end
18
+
19
+ # Native wait.h values differ: Linux WNOWAIT=0x01000000, Darwin=0x20.
20
+ case RUBY_PLATFORM
21
+ when /linux/
22
+ @options = WEXITED | 0x01000000
23
+ @information_size = 128 # Linux siginfo_t has a fixed 128-byte ABI.
24
+ when /darwin/
25
+ @options = WEXITED | 0x00000020
26
+ @information_size = 6 * Fiddle::SIZEOF_INT + 2 * Fiddle::SIZEOF_VOIDP + 8 * Fiddle::SIZEOF_LONG
27
+ else
28
+ raise UnsupportedFeatureError.new("native process waiting is unavailable on this platform", phase: :admission)
29
+ end
30
+
31
+ @waitid = Fiddle::Function.new(Fiddle::Handle::DEFAULT["waitid"],
32
+ [Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT],
33
+ Fiddle::TYPE_INT, need_gvl: false)
34
+ verify_available
35
+ rescue Fiddle::DLError
36
+ raise UnsupportedFeatureError.new("native waitid is unavailable", phase: :admission), cause: nil
37
+ end
38
+
39
+ def observe(pid)
40
+ raise ArgumentError, "child PID must be a positive integer" unless pid.is_a?(Integer) && pid.positive?
41
+
42
+ Fiddle::Pointer.malloc(@information_size, Fiddle::RUBY_FREE) do |information|
43
+ loop do
44
+ return if @waitid.call(P_PID, pid, information, @options).zero?
45
+
46
+ error = Fiddle.last_error
47
+ next if error == Errno::EINTR::Errno
48
+
49
+ raise SystemCallError.new("waitid", error)
50
+ end
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def verify_available
57
+ Fiddle::Pointer.malloc(@information_size, Fiddle::RUBY_FREE) do |information|
58
+ # A process cannot be its own child. Probe flags before owning a client.
59
+ status = @waitid.call(P_PID, Process.pid, information, @options | WNOHANG)
60
+ return if status == -1 && Fiddle.last_error == Errno::ECHILD::Errno
61
+ end
62
+ raise UnsupportedFeatureError.new("native non-reaping process observation is unavailable", phase: :admission)
63
+ end
64
+ end
65
+ end
66
+ end