diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e28744..9a2b56c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,38 @@ All notable changes to this project will be documented in this file. ## Unreleased +### Added + +- `Landlock.capture` results now report monotonic elapsed time and user CPU, system CPU, total CPU, and peak resident memory usage for the direct child selected by `wait4`. Operating-system accounting may include descendants that child reaped. Resource usage remains available for unsuccessful exits, signals, timeouts, and output-limit termination. + + ```ruby + result = Landlock.capture( + ["magick", input_path, "-resize", "800x800>", output_path], + read: [input_path, "/usr", "/lib", "/lib64"], + write: [File.dirname(output_path)], + execute: ["/usr", "/lib", "/lib64"] + ) + + usage = result.resource_usage + + result.elapsed_seconds # Wall-clock seconds + usage.user_seconds # CPU seconds spent in user mode + usage.system_seconds # CPU seconds spent in kernel mode + usage.cpu_seconds # user_seconds + system_seconds + usage.max_rss_bytes # Peak resident memory in bytes + ``` + + `Landlock.capture!` exposes the same measurements on failed commands through `Landlock::CommandError#result`: + + ```ruby + begin + Landlock.capture!(command, rlimits: { cpu_seconds: 5 }) + rescue Landlock::CommandError => error + error.result.elapsed_seconds + error.result.resource_usage.cpu_seconds + end + ``` + ## [0.4] - 2026-08-10 ### Changed diff --git a/README.md b/README.md index 0c05a7b..4dea15a 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ result = Landlock.capture( metadata = JSON.parse(result.stdout) if result.success? ``` -`Landlock.capture` takes the command as a single argv array, like `Landlock.exec`. It returns a `Landlock::CaptureResult` with `stdout`, `stderr`, `status`, `success?`, `timed_out?`, and `output_truncated?`, including for unsuccessful exit statuses. It also supports array destructuring: +`Landlock.capture` takes the command as a single argv array, like `Landlock.exec`. It returns a `Landlock::CaptureResult` with `stdout`, `stderr`, `status`, `success?`, `timed_out?`, `output_truncated?`, `elapsed_seconds`, and `resource_usage` for the direct child selected by `wait4`, including for unsuccessful exit statuses. Operating-system accounting may include descendants that child reaped. Resource usage exposes `user_seconds`, `system_seconds`, their sum as `cpu_seconds`, and `max_rss_bytes`. It also supports array destructuring: ```ruby stdout, stderr, status = Landlock.capture( diff --git a/ext/landlock/extconf.rb b/ext/landlock/extconf.rb index 69f8e59..f894779 100644 --- a/ext/landlock/extconf.rb +++ b/ext/landlock/extconf.rb @@ -12,7 +12,9 @@ have_header("sys/prctl.h") have_header("sys/syscall.h") have_header("sys/resource.h") +have_header("sys/wait.h") have_header("fcntl.h") +have_func("wait4", %w[sys/resource.h sys/wait.h]) create_makefile("landlock/landlock") diff --git a/ext/landlock/landlock.c b/ext/landlock/landlock.c index 63a2026..2237dfa 100644 --- a/ext/landlock/landlock.c +++ b/ext/landlock/landlock.c @@ -1,13 +1,25 @@ #include "ruby.h" +#include "ruby/thread.h" #include "landlock_native.h" #include "seccomp_deny_network.h" #include +#include +#include static VALUE mLandlock; static VALUE eLandlockError; static VALUE eSyscallError; +struct rb_landlock_wait4_args { + pid_t pid; + int flags; + int status; + int error_number; + struct rusage usage; + pid_t waited_pid; +}; + static void raise_syscall_error(const char *syscall_name) { int saved_errno = errno; VALUE err = rb_funcall(eSyscallError, rb_intern("new"), 3, rb_str_new_cstr(syscall_name), @@ -129,6 +141,50 @@ static VALUE rb_ll_seccomp_deny_network(VALUE self) { return Qtrue; } +#ifdef HAVE_WAIT4 +static void *ll_wait4_without_gvl(void *pointer) { + struct rb_landlock_wait4_args *args = pointer; + args->waited_pid = wait4(args->pid, &args->status, args->flags, &args->usage); + args->error_number = args->waited_pid < 0 ? errno : 0; + return NULL; +} +#endif + +static VALUE rb_ll_wait4(VALUE self, VALUE pid_value, VALUE flags_value) { +#ifdef HAVE_WAIT4 + struct rb_landlock_wait4_args args; + args.pid = (pid_t)NUM2LONG(pid_value); + args.flags = NUM2INT(flags_value); + + do { + rb_thread_call_without_gvl(ll_wait4_without_gvl, &args, RUBY_UBF_IO, NULL); + } while (args.waited_pid < 0 && args.error_number == EINTR); + + if (args.waited_pid == 0) { + return Qnil; + } + if (args.waited_pid < 0) { + rb_syserr_fail(args.error_number, "wait4"); + } + + rb_last_status_set(args.status, args.waited_pid); + + VALUE user_seconds = + DBL2NUM((double)args.usage.ru_utime.tv_sec + (double)args.usage.ru_utime.tv_usec / 1000000.0); + VALUE system_seconds = + DBL2NUM((double)args.usage.ru_stime.tv_sec + (double)args.usage.ru_stime.tv_usec / 1000000.0); +#ifdef __linux__ + VALUE max_rss_bytes = ULL2NUM((unsigned long long)args.usage.ru_maxrss * 1024ULL); +#else + VALUE max_rss_bytes = ULL2NUM((unsigned long long)args.usage.ru_maxrss); +#endif + + return rb_ary_new_from_args(4, rb_last_status_get(), user_seconds, system_seconds, max_rss_bytes); +#else + rb_raise(rb_eNotImpError, "wait4 is unavailable on this platform"); +#endif +} + void Init_landlock(void) { mLandlock = rb_define_module("Landlock"); @@ -150,6 +206,7 @@ void Init_landlock(void) { rb_define_singleton_method(mLandlock, "_add_net_rule", rb_ll_add_net_rule, 3); rb_define_singleton_method(mLandlock, "_restrict_self", rb_ll_restrict_self, 1); rb_define_singleton_method(mLandlock, "_close_fd", rb_ll_close_fd, 1); + rb_define_singleton_method(mLandlock, "_wait4", rb_ll_wait4, 2); rb_define_singleton_method(mLandlock, "seccomp_deny_network!", rb_ll_seccomp_deny_network, 0); rb_define_const(mLandlock, "ACCESS_FS_EXECUTE", ULL2NUM(LANDLOCK_ACCESS_FS_EXECUTE)); diff --git a/lib/landlock/native.rb b/lib/landlock/native.rb index 6035f17..6346c50 100644 --- a/lib/landlock/native.rb +++ b/lib/landlock/native.rb @@ -2,6 +2,7 @@ require_relative "errors" require_relative "landlock" +require_relative "result" module Landlock module Native @@ -31,6 +32,14 @@ def close_fd(fd) Landlock.__send__(:_close_fd, fd) end + def wait4(pid, flags) + result = Landlock.__send__(:_wait4, pid, flags) + return unless result + + status, user_seconds, system_seconds, max_rss_bytes = result + [status, ResourceUsage.new(user_seconds:, system_seconds:, max_rss_bytes:)] + end + def seccomp_deny_network! Landlock.seccomp_deny_network! end diff --git a/lib/landlock/process_io.rb b/lib/landlock/process_io.rb index 904dd94..bb5ebf4 100644 --- a/lib/landlock/process_io.rb +++ b/lib/landlock/process_io.rb @@ -1,11 +1,14 @@ # frozen_string_literal: true require_relative "errors" +require_relative "native" require_relative "result" module Landlock READ_CHUNK_BYTES = 16 * 1024 PROCESS_POLL_SECONDS = 0.1 + TERMINATION_POLL_SECONDS = 0.01 + TERMINATION_GRACE_SECONDS = 0.5 STDIN_THREAD_JOIN_SECONDS = 0.1 POST_TIMEOUT_DRAIN_SECONDS = 0.05 @@ -22,36 +25,46 @@ def complete_pipe_capture( max_output_bytes, truncate_output ) + started_at = monotonic_time stdin_thread = write_input(stdin_writer, stdin) stdout = +"".b stderr = +"".b - state = { bytes: 0, truncated: false } + state = { bytes: 0, truncated: false, wait_result: nil, timed_out: false, elapsed_seconds: nil, started_at: } begin - status, timed_out = - read_and_wait( - pid, - { stdout_reader => stdout, stderr_reader => stderr }, - timeout, - max_output_bytes, - truncate_output, - state - ) + read_and_wait( + pid, + { stdout_reader => stdout, stderr_reader => stderr }, + timeout, + max_output_bytes, + truncate_output, + state + ) rescue OutputTooLargeError => error - status ||= wait_for_pid(pid) - error.result = capture_result(stdout, stderr, status, output_truncated: true, timed_out:) + record_wait_result(state, wait_for_pid(pid)) unless state[:wait_result] + state[:elapsed_seconds] ||= monotonic_time - started_at + error.result = capture_result(stdout:, stderr:, state:, output_truncated: true) raise ensure finish_input_thread(stdin_thread, stdin_writer) end - capture_result(stdout, stderr, status, output_truncated: state[:truncated], timed_out:) + capture_result(stdout:, stderr:, state:, output_truncated: state[:truncated]) end - def capture_result(stdout, stderr, status, output_truncated:, timed_out:) + def capture_result(stdout:, stderr:, state:, output_truncated:) stdout.force_encoding(Encoding.default_external) stderr.force_encoding(Encoding.default_external) - CaptureResult.new(stdout:, stderr:, status:, output_truncated:, timed_out:) + status, resource_usage = state[:wait_result] + CaptureResult.new( + stdout:, + stderr:, + status:, + elapsed_seconds: state[:elapsed_seconds], + resource_usage:, + output_truncated:, + timed_out: state[:timed_out] + ) end def write_input(io, input) @@ -87,20 +100,18 @@ def finish_input_thread(thread, io) end def read_and_wait(pid, streams, timeout, max_output_bytes, truncate_output, state) - deadline = timeout ? ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout : nil - timed_out = false - status = nil + deadline = timeout ? state[:started_at] + timeout : nil - until streams.empty? && status + until streams.empty? && state[:wait_result] if deadline - remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + remaining = deadline - monotonic_time if remaining <= 0 - timed_out = true - terminate_process(pid) - status = wait_for_pid(pid) + state[:timed_out] = true + wait_result, reaped_at = terminate_and_wait(pid) + record_wait_result(state, wait_result, reaped_at:) drain_streams_until( streams, - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + POST_TIMEOUT_DRAIN_SECONDS, + monotonic_time + POST_TIMEOUT_DRAIN_SECONDS, max_output_bytes, truncate_output, state, @@ -111,24 +122,29 @@ def read_and_wait(pid, streams, timeout, max_output_bytes, truncate_output, stat end end - status ||= poll_pid(pid) + record_wait_result(state, poll_pid(pid)) unless state[:wait_result] + + break if streams.empty? && state[:wait_result] - break if streams.empty? && status + if streams.empty? && deadline + sleep [deadline - monotonic_time, TERMINATION_POLL_SECONDS].min.clamp(0, TERMINATION_POLL_SECONDS) + next + end + + if streams.empty? + record_wait_result(state, wait_for_pid(pid)) + break + end wait = ( if deadline - [deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC), PROCESS_POLL_SECONDS].min + [deadline - monotonic_time, PROCESS_POLL_SECONDS].min else PROCESS_POLL_SECONDS end ) wait = 0 if wait.negative? - if streams.empty? - sleep wait - next - end - readable, = IO.select(streams.keys, nil, nil, wait) next unless readable @@ -145,23 +161,29 @@ def read_and_wait(pid, streams, timeout, max_output_bytes, truncate_output, stat end end - status ||= wait_for_pid(pid) - [status, timed_out] + record_wait_result(state, wait_for_pid(pid)) unless state[:wait_result] end def poll_pid(pid) - result = ::Process.wait2(pid, ::Process::WNOHANG) - result&.last + Native.wait4(pid, ::Process::WNOHANG) rescue Errno::ECHILD nil end def wait_for_pid(pid) - ::Process.wait2(pid).last + Native.wait4(pid, 0) rescue Errno::ECHILD nil end + def record_wait_result(state, wait_result, reaped_at: monotonic_time) + return unless wait_result + return if state[:wait_result] + + state[:wait_result] = wait_result + state[:elapsed_seconds] = reaped_at - state[:started_at] + end + def close_stream(io) io.close unless io.closed? rescue IOError @@ -187,7 +209,7 @@ def read_available_streams(streams, max_output_bytes, truncate_output, state, pi end def drain_streams_until(streams, drain_deadline, max_output_bytes, truncate_output, state, pid) - while streams.any? && ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) < drain_deadline + while streams.any? && monotonic_time < drain_deadline break unless read_available_streams(streams, max_output_bytes, truncate_output, state, pid) end end @@ -227,14 +249,37 @@ def append_output_chunk( buffer << chunk_to_append return unless over_limit - terminate_process(pid) + wait_result, reaped_at = terminate_and_wait(pid) + record_wait_result(state, wait_result, reaped_at:) raise output_too_large_error, "Process output exceeded #{max_output_bytes} bytes" unless truncate_output end - def terminate_process(pid) + def terminate_and_wait(pid) signal_process("TERM", pid) - sleep 0.5 - signal_process("KILL", pid) + deadline = monotonic_time + TERMINATION_GRACE_SECONDS + wait_result = nil + reaped_at = nil + + loop do + unless wait_result + wait_result = poll_pid(pid) + reaped_at = monotonic_time if wait_result + end + break unless process_group_alive?(pid) + + remaining_seconds = deadline - monotonic_time + break if remaining_seconds <= 0 + + sleep [remaining_seconds, TERMINATION_POLL_SECONDS].min + end + + signal_process("KILL", pid) if process_group_alive?(pid) + unless wait_result + wait_result = wait_for_pid(pid) + reaped_at = monotonic_time if wait_result + end + + [wait_result, reaped_at] end def signal_process(signal, pid) @@ -245,5 +290,18 @@ def signal_process(signal, pid) rescue Errno::ESRCH, Errno::EPERM end end + + def process_group_alive?(pid) + ::Process.kill(0, -pid) + true + rescue Errno::ESRCH + false + rescue Errno::EPERM + true + end + + def monotonic_time + ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + end end end diff --git a/lib/landlock/result.rb b/lib/landlock/result.rb index 3966fad..46c8b4b 100644 --- a/lib/landlock/result.rb +++ b/lib/landlock/result.rb @@ -1,11 +1,18 @@ # frozen_string_literal: true module Landlock + ResourceUsage = + Data.define(:user_seconds, :system_seconds, :max_rss_bytes) do + def cpu_seconds + user_seconds + system_seconds + end + end + module ResultBehavior - attr_reader :stdout, :stderr, :status + attr_reader :stdout, :stderr, :status, :elapsed_seconds, :resource_usage def success? - !timed_out? && status&.success? + !timed_out? && !output_truncated? && status&.success? end def output_truncated? @@ -25,17 +32,27 @@ def to_s end def inspect - "#<#{self.class} status=#{status.inspect} timed_out=#{timed_out?} output_truncated=#{output_truncated?} stdout=#{stdout.inspect} stderr=#{stderr.inspect}>" + "#<#{self.class} status=#{status.inspect} timed_out=#{timed_out?} output_truncated=#{output_truncated?} elapsed_seconds=#{elapsed_seconds.inspect} resource_usage=#{resource_usage.inspect} stdout=#{stdout.inspect} stderr=#{stderr.inspect}>" end end class CaptureResult include ResultBehavior - def initialize(stdout:, stderr:, status:, output_truncated: false, timed_out: false) + def initialize( + stdout:, + stderr:, + status:, + elapsed_seconds: nil, + resource_usage: nil, + output_truncated: false, + timed_out: false + ) @stdout = stdout @stderr = stderr @status = status + @elapsed_seconds = elapsed_seconds + @resource_usage = resource_usage @output_truncated = output_truncated @timed_out = timed_out end diff --git a/lib/landlock/runner/fork.rb b/lib/landlock/runner/fork.rb index 3200b2f..9fe87b3 100644 --- a/lib/landlock/runner/fork.rb +++ b/lib/landlock/runner/fork.rb @@ -129,10 +129,7 @@ def call( rescue OutputTooLargeError raise rescue Exception - if pid - ProcessIO.terminate_process(pid) - ProcessIO.wait_for_pid(pid) - end + ProcessIO.terminate_and_wait(pid) if pid raise ensure [stdin_reader, stdin_writer, stdout_reader, stdout_writer, stderr_reader, stderr_writer].each do |io| diff --git a/lib/landlock/runner/native.rb b/lib/landlock/runner/native.rb index 9a7b64d..f778ab3 100644 --- a/lib/landlock/runner/native.rb +++ b/lib/landlock/runner/native.rb @@ -128,10 +128,7 @@ def call( rescue OutputTooLargeError raise rescue Exception - if pid - ProcessIO.terminate_process(pid) - ProcessIO.wait_for_pid(pid) - end + ProcessIO.terminate_and_wait(pid) if pid raise ensure [stdin_reader, stdin_writer, stdout_reader, stdout_writer, stderr_reader, stderr_writer].each do |io| diff --git a/test/landlock_capture_test.rb b/test/landlock_capture_test.rb index b2d796b..50c4d76 100644 --- a/test/landlock_capture_test.rb +++ b/test/landlock_capture_test.rb @@ -105,6 +105,25 @@ def test_capture_does_not_false_timeout_after_streams_close refute result.timed_out? end + def test_capture_timeout_still_applies_after_streams_close + skip "Landlock unsupported" unless Landlock.supported? + + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + result = + Landlock.capture( + [RbConfig.ruby, "--disable=gems", "-e", "STDOUT.close; STDERR.close; sleep 30"], + rlimits: { + open_files: 64 + }, + timeout: 0.1 + ) + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at + + assert_operator elapsed, :<, 0.5 + assert_predicate result, :timed_out? + assert_kind_of Landlock::ResourceUsage, result.resource_usage + end + def test_capture_does_not_wait_forever_for_blocked_stdin_reader skip "Landlock unsupported" unless Landlock.supported? diff --git a/test/resource_usage_test.rb b/test/resource_usage_test.rb new file mode 100644 index 0000000..bc646ef --- /dev/null +++ b/test/resource_usage_test.rb @@ -0,0 +1,323 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class LandlockResourceUsageTest < LandlockTestCase + def test_capture_result_new_metrics_are_optional_and_inspectable + result = Landlock::CaptureResult.new(stdout: "out", stderr: "err", status: nil) + + assert_nil result.elapsed_seconds + assert_nil result.resource_usage + assert_includes result.inspect, "elapsed_seconds=nil" + assert_includes result.inspect, "resource_usage=nil" + end + + def test_successful_capture_exposes_elapsed_time_and_resource_usage + skip "Landlock unsupported" unless Landlock.supported? + + result = capture_command("sleep 0.05; print 'ok'") + + assert_equal "ok", result.stdout + assert_operator result.elapsed_seconds, :>=, 0.05 + assert_operator result.elapsed_seconds, :<, 2 + assert_operator result.resource_usage.user_seconds, :>=, 0 + assert_operator result.resource_usage.system_seconds, :>=, 0 + assert_in_delta( + result.resource_usage.user_seconds + result.resource_usage.system_seconds, + result.resource_usage.cpu_seconds, + 0.000001 + ) + assert_operator result.resource_usage.cpu_seconds, :>, 0 + assert_operator result.resource_usage.max_rss_bytes, :>, 1024 * 1024 + assert_predicate result.resource_usage, :frozen? + end + + def test_capture_preserves_three_value_destructuring + skip "Landlock unsupported" unless Landlock.supported? + + result = capture_command("$stdout.print 'out'; $stderr.print 'err'; exit 7") + stdout, stderr, status = result + + assert_equal "out", stdout + assert_equal "err", stderr + assert_equal 7, status.exitstatus + assert_kind_of Landlock::ResourceUsage, result.resource_usage + end + + def test_capture_bang_error_exposes_complete_nonzero_result + skip "Landlock unsupported" unless Landlock.supported? + + error = assert_raises(Landlock::CommandError) { capture_command!("exit 7") } + + assert_equal 7, error.status.exitstatus + assert_operator error.result.elapsed_seconds, :>, 0 + assert_kind_of Landlock::ResourceUsage, error.result.resource_usage + assert_operator error.result.resource_usage.max_rss_bytes, :>, 0 + end + + def test_signal_termination_preserves_resource_usage + skip "Landlock unsupported" unless Landlock.supported? + + result = capture_command("Process.kill('TERM', Process.pid)") + + assert_predicate result.status, :signaled? + assert_equal Signal.list.fetch("TERM"), result.status.termsig + assert_operator result.elapsed_seconds, :>, 0 + assert_operator result.resource_usage.cpu_seconds, :>, 0 + assert_operator result.resource_usage.max_rss_bytes, :>, 0 + end + + def test_wall_timeout_preserves_resource_usage + skip "Landlock unsupported" unless Landlock.supported? + + result = capture_command("loop { sleep 1 }", timeout: 0.1) + + assert_predicate result, :timed_out? + assert_operator result.elapsed_seconds, :>=, 0.1 + assert_operator result.elapsed_seconds, :<, 0.4 + assert_kind_of Landlock::ResourceUsage, result.resource_usage + assert_operator result.resource_usage.max_rss_bytes, :>, 0 + end + + def test_output_limit_error_preserves_resource_usage_and_partial_output + skip "Landlock unsupported" unless Landlock.supported? + + error = assert_raises(Landlock::CommandError) { capture_command!("print 'x' * 1024", max_output_bytes: 10) } + + assert_equal "x" * 10, error.result.stdout + assert_predicate error.result, :output_truncated? + refute_predicate error.result, :timed_out? + refute_predicate error.result, :success? + assert_operator error.result.elapsed_seconds, :>, 0 + assert_kind_of Landlock::ResourceUsage, error.result.resource_usage + end + + def test_output_limit_error_preserves_metrics_reaped_before_later_output + skip "Landlock unsupported" unless Landlock.supported? + skip "native runner helper unavailable" unless File.executable?(Landlock::Runner::Native.helper_path) + + [Landlock::Runner::Native, Landlock::Runner::Fork].each do |runner| + error = + assert_raises(Landlock::OutputTooLargeError) do + capture_backend_result( + runner, + [ + RbConfig.ruby, + "--disable=gems", + "-e", + "Process.fork { sleep 0.2; STDOUT.write('x' * #{Landlock::READ_CHUNK_BYTES * 2}); sleep 30 }; exit 0" + ], + rlimits: { + open_files: 64 + }, + max_output_bytes: Landlock::READ_CHUNK_BYTES + 1 + ) + end + result = error.result + + assert_predicate result.status, :success?, runner.name + assert_kind_of Landlock::ResourceUsage, result.resource_usage, runner.name + assert_equal Landlock::READ_CHUNK_BYTES + 1, result.stdout.bytesize, runner.name + refute_predicate result, :timed_out?, runner.name + refute_predicate result, :success?, runner.name + end + end + + def test_truncated_capture_preserves_resource_usage + skip "Landlock unsupported" unless Landlock.supported? + + result = capture_command("print 'x' * 1024", max_output_bytes: 10, truncate_output: true) + + assert_equal "x" * 10, result.stdout + assert_predicate result, :output_truncated? + assert_operator result.elapsed_seconds, :>, 0 + assert_kind_of Landlock::ResourceUsage, result.resource_usage + end + + def test_cpu_limit_preserves_resource_usage + skip "Landlock unsupported" unless Landlock.supported? + + result = capture_command("loop {}", rlimits: { cpu_seconds: 1 }, timeout: 5) + + assert_predicate result.status, :signaled? + assert_operator result.resource_usage.cpu_seconds, :>=, 0.5 + assert_operator result.resource_usage.max_rss_bytes, :>, 0 + assert_operator result.elapsed_seconds, :<, 5 + end + + def test_file_size_limit_preserves_resource_usage + skip "Landlock unsupported" unless Landlock.supported? + + Dir.mktmpdir do |dir| + output_path = File.join(dir, "output") + result = + Landlock.capture( + [ + "/bin/bash", + "-c", + "trap - XFSZ; exec /usr/bin/dd if=/dev/zero of=\"$1\" bs=4096 count=1", + "landlock-file-size-test", + output_path + ], + read: runtime_paths + ["/dev/zero"], + write: [dir], + execute: runtime_paths, + rlimits: { + file_size_bytes: 1024 + } + ) + + assert_predicate result.status, :signaled? + assert_equal Signal.list.fetch("XFSZ"), result.status.termsig + assert_equal 1024, File.size(output_path) + assert_operator result.elapsed_seconds, :>, 0 + assert_kind_of Landlock::ResourceUsage, result.resource_usage + end + end + + def test_concurrent_captures_keep_each_child_resource_usage_separate + skip "Landlock unsupported" unless Landlock.supported? + + start = Queue.new + light_thread = + Thread.new do + start.pop + capture_command("sleep 0.5") + end + heavy_thread = + Thread.new do + start.pop + capture_command( + "payload = 'x' * (64 * 1024 * 1024); deadline = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID) + 0.3; loop { break if Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID) >= deadline }; print payload.bytesize" + ) + end + 2.times { start << true } + + light_result = light_thread.value + heavy_result = heavy_thread.value + + assert_equal "", light_result.stdout + assert_equal (64 * 1024 * 1024).to_s, heavy_result.stdout + assert_operator heavy_result.resource_usage.cpu_seconds, :>, light_result.resource_usage.cpu_seconds + 0.15 + assert_operator heavy_result.resource_usage.max_rss_bytes, + :>, + light_result.resource_usage.max_rss_bytes + 16 * 1024 * 1024 + assert_operator light_result.elapsed_seconds, :<, 2 + assert_operator heavy_result.elapsed_seconds, :<, 2 + end + + def test_reaped_child_metrics_survive_timeout_draining_pipes_held_by_escaped_descendant + skip "Landlock unsupported" unless Landlock.supported? + skip "native runner helper unavailable" unless File.executable?(Landlock::Runner::Native.helper_path) + + [Landlock::Runner::Native, Landlock::Runner::Fork].each do |runner| + Dir.mktmpdir do |dir| + pidfile = File.join(dir, "escaped.pid") + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + result = + capture_backend_result( + runner, + [ + RbConfig.ruby, + "--disable=gems", + "-e", + "Process.fork { Process.setsid; File.write(ARGV.fetch(0), Process.pid); sleep 30 }; exit 0", + pidfile + ], + timeout: 0.3, + rlimits: { + open_files: 64 + } + ) + capture_elapsed_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at + + assert_predicate result, :timed_out?, runner.name + assert_predicate result.status, :success?, runner.name + assert_kind_of Landlock::ResourceUsage, result.resource_usage, runner.name + assert_operator result.elapsed_seconds, :<, 0.3, runner.name + assert_operator capture_elapsed_seconds, :>=, 0.3, runner.name + assert_operator capture_elapsed_seconds, :<, 0.8, runner.name + ensure + kill_process_from_file(pidfile) + end + end + end + + def test_native_blocking_wait_allows_other_ruby_threads_to_run + pid = Process.spawn(RbConfig.ruby, "--disable=gems", "-e", "sleep 0.5") + ready = Queue.new + begin_wait = Queue.new + ticked = Queue.new + ticker = + Thread.new do + ready << true + begin_wait.pop + sleep 0.01 + ticked << true + end + ready.pop + begin_wait << true + + status, resource_usage = Landlock::Native.wait4(pid, 0) + + assert_predicate status, :success? + assert_kind_of Landlock::ResourceUsage, resource_usage + assert_operator resource_usage.max_rss_bytes, :>, 1024 * 1024 + refute_predicate ticked, :empty? + ensure + ticker&.kill + ticker&.join + terminate_and_reap(pid) if pid + end + + def test_native_blocking_wait_can_be_interrupted + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + pid = Process.spawn(RbConfig.ruby, "--disable=gems", "-e", "sleep 5") + waiter = Thread.new { Landlock::Native.wait4(pid, 0) } + sleep 0.05 + + waiter.kill + + assert waiter.join(0.5) + assert_operator Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at, :<, 1 + ensure + waiter&.kill + waiter&.join + terminate_and_reap(pid) if pid + end + + private + + def capture_command(script, rlimits: { open_files: 64 }, **options) + Landlock.capture( + [RbConfig.ruby, "--disable=gems", "-e", script], + rlimits:, + env: { + "PATH" => ENV.fetch("PATH", "") + }, + unsetenv_others: true, + **options + ) + end + + def capture_command!(script, **options) + Landlock.capture!( + [RbConfig.ruby, "--disable=gems", "-e", script], + rlimits: { + open_files: 64 + }, + env: { + "PATH" => ENV.fetch("PATH", "") + }, + unsetenv_others: true, + **options + ) + end + + def terminate_and_reap(pid) + Process.kill("KILL", pid) + Process.wait(pid) + rescue Errno::ESRCH, Errno::ECHILD + nil + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 5d21385..d21be11 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -28,6 +28,23 @@ def assert_capture_backends_equivalent(name, argv:, **options) assert_equal forked.success?, native.success?, "#{name}: success" assert_equal forked.timed_out?, native.timed_out?, "#{name}: timed_out" assert_equal forked.output_truncated?, native.output_truncated?, "#{name}: output_truncated" + assert_kind_of Float, native.elapsed_seconds, "#{name}: native elapsed_seconds" + assert_kind_of Float, forked.elapsed_seconds, "#{name}: fork elapsed_seconds" + assert_kind_of Landlock::ResourceUsage, native.resource_usage, "#{name}: native resource_usage" + assert_kind_of Landlock::ResourceUsage, forked.resource_usage, "#{name}: fork resource_usage" + assert_operator native.elapsed_seconds, :>=, 0, "#{name}: native elapsed_seconds" + assert_operator forked.elapsed_seconds, :>=, 0, "#{name}: fork elapsed_seconds" + assert_operator native.resource_usage.max_rss_bytes, :>, 0, "#{name}: native max_rss_bytes" + assert_operator forked.resource_usage.max_rss_bytes, :>, 0, "#{name}: fork max_rss_bytes" + assert_in_delta forked.elapsed_seconds, native.elapsed_seconds, 0.5, "#{name}: elapsed_seconds parity" + assert_in_delta forked.resource_usage.cpu_seconds, + native.resource_usage.cpu_seconds, + 0.5, + "#{name}: cpu_seconds parity" + assert_in_delta forked.resource_usage.max_rss_bytes, + native.resource_usage.max_rss_bytes, + 64 * 1024 * 1024, + "#{name}: max_rss_bytes parity" end def capture_backend_result(runner, argv, **options)