From b1c99e7863732123a9972c5c14597ba006c9eb54 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Thu, 20 Aug 2026 10:56:30 +0800 Subject: [PATCH 1/3] FEATURE: Track resource usage in `Landlock.capture` Capture results previously exposed output and status without reporting how long the child ran or which CPU and memory resources it consumed. Callers could not measure sandboxed command cost, and process-wide accounting could not safely distinguish concurrent children. This commit reaps capture children with native `wait4`, collecting the matching status and `rusage` atomically. Native waits release the GVL and remain interruptible, while capture cleanup uses nonblocking polling so other Ruby threads can continue running. `CaptureResult` now exposes monotonic elapsed time and an immutable `ResourceUsage` value for successful and failed commands, with Linux peak RSS normalized to bytes. Once a child is reaped, retained descendant pipes cannot replace its status, usage, or completion time during timeout cleanup. The existing three-value destructuring contract remains unchanged, and `capture!` errors continue to carry the complete result. --- CHANGELOG.md | 4 + README.md | 2 +- ext/landlock/extconf.rb | 2 + ext/landlock/landlock.c | 52 +++++++ lib/landlock/native.rb | 9 ++ lib/landlock/process_io.rb | 68 ++++++--- lib/landlock/result.rb | 21 ++- test/resource_usage_test.rb | 279 ++++++++++++++++++++++++++++++++++++ test/test_helper.rb | 17 +++ 9 files changed, 429 insertions(+), 25 deletions(-) create mode 100644 test/resource_usage_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e28744..8b7c0aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## Unreleased +### Added + +- `Landlock.capture` results now report monotonic elapsed time and per-child user CPU, system CPU, total CPU, and peak resident memory usage. Resource usage remains available for unsuccessful exits, signals, timeouts, and output-limit termination. + ## [0.4] - 2026-08-10 ### Changed diff --git a/README.md b/README.md index 0c05a7b..df8385c 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 per-child `resource_usage`, including for unsuccessful exit statuses. 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..189f624 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") +abort "missing wait4" unless 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..953a2fc 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,45 @@ static VALUE rb_ll_seccomp_deny_network(VALUE self) { return Qtrue; } +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; +} + +static VALUE rb_ll_wait4(VALUE self, VALUE pid_value, VALUE flags_value) { + struct rb_landlock_wait4_args args; + args.pid = (pid_t)NUM2LONG(pid_value); + args.flags = NUM2INT(flags_value); + + do { + memset(&args.usage, 0, sizeof(args.usage)); + 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); +} + void Init_landlock(void) { mLandlock = rb_define_module("Landlock"); @@ -150,6 +201,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..f3acb23 100644 --- a/lib/landlock/process_io.rb +++ b/lib/landlock/process_io.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative "errors" +require_relative "native" require_relative "result" module Landlock @@ -22,36 +23,41 @@ 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 } begin - status, timed_out = + wait_result, timed_out, elapsed_seconds = read_and_wait( pid, { stdout_reader => stdout, stderr_reader => stderr }, + started_at, 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:) + wait_result ||= wait_for_pid(pid) + elapsed_seconds ||= monotonic_time - started_at + error.result = + capture_result(stdout:, stderr:, wait_result:, elapsed_seconds:, output_truncated: true, timed_out:) 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:, wait_result:, elapsed_seconds:, output_truncated: state[:truncated], timed_out:) end - def capture_result(stdout, stderr, status, output_truncated:, timed_out:) + def capture_result(stdout:, stderr:, wait_result:, elapsed_seconds:, output_truncated:, timed_out:) stdout.force_encoding(Encoding.default_external) stderr.force_encoding(Encoding.default_external) - CaptureResult.new(stdout:, stderr:, status:, output_truncated:, timed_out:) + status, resource_usage = wait_result + CaptureResult.new(stdout:, stderr:, status:, elapsed_seconds:, resource_usage:, output_truncated:, timed_out:) end def write_input(io, input) @@ -86,21 +92,25 @@ def finish_input_thread(thread, io) end end - def read_and_wait(pid, streams, timeout, max_output_bytes, truncate_output, state) - deadline = timeout ? ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout : nil + def read_and_wait(pid, streams, started_at, timeout, max_output_bytes, truncate_output, state) + deadline = timeout ? started_at + timeout : nil timed_out = false - status = nil + wait_result = nil + elapsed_seconds = nil - until streams.empty? && status + until streams.empty? && 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) + unless wait_result + wait_result = wait_for_pid(pid) + elapsed_seconds = monotonic_time - started_at + end 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,14 +121,17 @@ def read_and_wait(pid, streams, timeout, max_output_bytes, truncate_output, stat end end - status ||= poll_pid(pid) + unless wait_result + wait_result = poll_pid(pid) + elapsed_seconds = monotonic_time - started_at if wait_result + end - break if streams.empty? && status + break if streams.empty? && wait_result 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 @@ -145,19 +158,26 @@ def read_and_wait(pid, streams, timeout, max_output_bytes, truncate_output, stat end end - status ||= wait_for_pid(pid) - [status, timed_out] + unless wait_result + wait_result = wait_for_pid(pid) + elapsed_seconds = monotonic_time - started_at + end + [wait_result, timed_out, elapsed_seconds] 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 + loop do + result = Native.wait4(pid, ::Process::WNOHANG) + return result if result + + sleep PROCESS_POLL_SECONDS + end rescue Errno::ECHILD nil end @@ -187,7 +207,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 @@ -245,5 +265,9 @@ def signal_process(signal, pid) rescue Errno::ESRCH, Errno::EPERM end 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..e0b1104 100644 --- a/lib/landlock/result.rb +++ b/lib/landlock/result.rb @@ -1,8 +1,15 @@ # 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? @@ -32,10 +39,20 @@ def inspect class CaptureResult include ResultBehavior - def initialize(stdout:, stderr:, status:, output_truncated: false, timed_out: false) + def initialize( + stdout:, + stderr:, + status:, + elapsed_seconds:, + resource_usage:, + 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/test/resource_usage_test.rb b/test/resource_usage_test.rb new file mode 100644 index 0000000..898bb25 --- /dev/null +++ b/test/resource_usage_test.rb @@ -0,0 +1,279 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class LandlockResourceUsageTest < LandlockTestCase + 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, :<, 2 + 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? + assert_operator error.result.elapsed_seconds, :>, 0 + assert_kind_of Landlock::ResourceUsage, error.result.resource_usage + 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_MONOTONIC) + 0.5; loop { break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= 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.2 + assert_operator heavy_result.resource_usage.max_rss_bytes, + :>, + light_result.resource_usage.max_rss_bytes + 32 * 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, :>, result.elapsed_seconds + 0.4, 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 + ticks = Queue.new + ticker = + Thread.new do + ready << true + loop do + sleep 0.01 + ticks << true + end + end + ready.pop + + 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 + assert_operator ticks.size, :>=, 10 + 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) From 52c890a4641ead105466b97e9356c3f4ada63f69 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Thu, 20 Aug 2026 12:08:35 +0800 Subject: [PATCH 2/3] DEV: Document capture resource metrics The changelog named the new measurements but did not show callers how to access them or clarify which process they describe. This commit adds successful and failed capture examples and describes the accounting as belonging to the direct process launched by each capture. --- CHANGELOG.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b7c0aa..2f39b81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,35 @@ All notable changes to this project will be documented in this file. ### Added -- `Landlock.capture` results now report monotonic elapsed time and per-child user CPU, system CPU, total CPU, and peak resident memory usage. Resource usage remains available for unsuccessful exits, signals, timeouts, and output-limit termination. +- `Landlock.capture` results now report monotonic elapsed time and user CPU, system CPU, total CPU, and peak resident memory usage for the direct process launched by the capture. 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 From eacd44b311eb56c2531ba099cde7fa17bf5ad702 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Thu, 20 Aug 2026 12:49:25 +0800 Subject: [PATCH 3/3] FIX: Preserve capture metrics across output limits --- CHANGELOG.md | 2 +- README.md | 2 +- ext/landlock/extconf.rb | 2 +- ext/landlock/landlock.c | 7 +- lib/landlock/process_io.rb | 144 +++++++++++++++++++++------------- lib/landlock/result.rb | 8 +- lib/landlock/runner/fork.rb | 5 +- lib/landlock/runner/native.rb | 5 +- test/landlock_capture_test.rb | 19 +++++ test/resource_usage_test.rb | 66 +++++++++++++--- 10 files changed, 178 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f39b81..9a2b56c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. ### Added -- `Landlock.capture` results now report monotonic elapsed time and user CPU, system CPU, total CPU, and peak resident memory usage for the direct process launched by the capture. Resource usage remains available for unsuccessful exits, signals, timeouts, and output-limit termination. +- `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( diff --git a/README.md b/README.md index df8385c..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?`, `output_truncated?`, `elapsed_seconds`, and per-child `resource_usage`, including for unsuccessful exit statuses. Resource usage exposes `user_seconds`, `system_seconds`, their sum as `cpu_seconds`, and `max_rss_bytes`. 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 189f624..f894779 100644 --- a/ext/landlock/extconf.rb +++ b/ext/landlock/extconf.rb @@ -14,7 +14,7 @@ have_header("sys/resource.h") have_header("sys/wait.h") have_header("fcntl.h") -abort "missing wait4" unless have_func("wait4", %w[sys/resource.h sys/wait.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 953a2fc..2237dfa 100644 --- a/ext/landlock/landlock.c +++ b/ext/landlock/landlock.c @@ -141,20 +141,22 @@ 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 { - memset(&args.usage, 0, sizeof(args.usage)); rb_thread_call_without_gvl(ll_wait4_without_gvl, &args, RUBY_UBF_IO, NULL); } while (args.waited_pid < 0 && args.error_number == EINTR); @@ -178,6 +180,9 @@ static VALUE rb_ll_wait4(VALUE self, VALUE pid_value, VALUE flags_value) { #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) { diff --git a/lib/landlock/process_io.rb b/lib/landlock/process_io.rb index f3acb23..bb5ebf4 100644 --- a/lib/landlock/process_io.rb +++ b/lib/landlock/process_io.rb @@ -7,6 +7,8 @@ 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 @@ -28,36 +30,41 @@ def complete_pipe_capture( 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 - wait_result, timed_out, elapsed_seconds = - read_and_wait( - pid, - { stdout_reader => stdout, stderr_reader => stderr }, - started_at, - 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 - wait_result ||= wait_for_pid(pid) - elapsed_seconds ||= monotonic_time - started_at - error.result = - capture_result(stdout:, stderr:, wait_result:, elapsed_seconds:, 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:, wait_result:, elapsed_seconds:, output_truncated: state[:truncated], timed_out:) + capture_result(stdout:, stderr:, state:, output_truncated: state[:truncated]) end - def capture_result(stdout:, stderr:, wait_result:, elapsed_seconds:, output_truncated:, timed_out:) + def capture_result(stdout:, stderr:, state:, output_truncated:) stdout.force_encoding(Encoding.default_external) stderr.force_encoding(Encoding.default_external) - status, resource_usage = wait_result - CaptureResult.new(stdout:, stderr:, status:, elapsed_seconds:, resource_usage:, 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) @@ -92,22 +99,16 @@ def finish_input_thread(thread, io) end end - def read_and_wait(pid, streams, started_at, timeout, max_output_bytes, truncate_output, state) - deadline = timeout ? started_at + timeout : nil - timed_out = false - wait_result = nil - elapsed_seconds = nil + def read_and_wait(pid, streams, timeout, max_output_bytes, truncate_output, state) + deadline = timeout ? state[:started_at] + timeout : nil - until streams.empty? && wait_result + until streams.empty? && state[:wait_result] if deadline remaining = deadline - monotonic_time if remaining <= 0 - timed_out = true - terminate_process(pid) - unless wait_result - wait_result = wait_for_pid(pid) - elapsed_seconds = monotonic_time - started_at - end + state[:timed_out] = true + wait_result, reaped_at = terminate_and_wait(pid) + record_wait_result(state, wait_result, reaped_at:) drain_streams_until( streams, monotonic_time + POST_TIMEOUT_DRAIN_SECONDS, @@ -121,12 +122,19 @@ def read_and_wait(pid, streams, started_at, timeout, max_output_bytes, truncate_ end end - unless wait_result - wait_result = poll_pid(pid) - elapsed_seconds = monotonic_time - started_at if wait_result + record_wait_result(state, poll_pid(pid)) unless state[:wait_result] + + break if streams.empty? && state[:wait_result] + + if streams.empty? && deadline + sleep [deadline - monotonic_time, TERMINATION_POLL_SECONDS].min.clamp(0, TERMINATION_POLL_SECONDS) + next end - break if streams.empty? && wait_result + if streams.empty? + record_wait_result(state, wait_for_pid(pid)) + break + end wait = ( @@ -137,11 +145,6 @@ def read_and_wait(pid, streams, started_at, timeout, max_output_bytes, truncate_ end ) wait = 0 if wait.negative? - if streams.empty? - sleep wait - next - end - readable, = IO.select(streams.keys, nil, nil, wait) next unless readable @@ -158,11 +161,7 @@ def read_and_wait(pid, streams, started_at, timeout, max_output_bytes, truncate_ end end - unless wait_result - wait_result = wait_for_pid(pid) - elapsed_seconds = monotonic_time - started_at - end - [wait_result, timed_out, elapsed_seconds] + record_wait_result(state, wait_for_pid(pid)) unless state[:wait_result] end def poll_pid(pid) @@ -172,16 +171,19 @@ def poll_pid(pid) end def wait_for_pid(pid) - loop do - result = Native.wait4(pid, ::Process::WNOHANG) - return result if result - - sleep PROCESS_POLL_SECONDS - end + 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 @@ -247,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) @@ -266,6 +291,15 @@ def signal_process(signal, pid) 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 diff --git a/lib/landlock/result.rb b/lib/landlock/result.rb index e0b1104..46c8b4b 100644 --- a/lib/landlock/result.rb +++ b/lib/landlock/result.rb @@ -12,7 +12,7 @@ module ResultBehavior 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? @@ -32,7 +32,7 @@ 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 @@ -43,8 +43,8 @@ def initialize( stdout:, stderr:, status:, - elapsed_seconds:, - resource_usage:, + elapsed_seconds: nil, + resource_usage: nil, output_truncated: false, timed_out: false ) 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 index 898bb25..bc646ef 100644 --- a/test/resource_usage_test.rb +++ b/test/resource_usage_test.rb @@ -3,6 +3,15 @@ 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? @@ -65,7 +74,7 @@ def test_wall_timeout_preserves_resource_usage assert_predicate result, :timed_out? assert_operator result.elapsed_seconds, :>=, 0.1 - assert_operator result.elapsed_seconds, :<, 2 + 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 @@ -77,10 +86,43 @@ def test_output_limit_error_preserves_resource_usage_and_partial_output 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? @@ -146,7 +188,7 @@ def test_concurrent_captures_keep_each_child_resource_usage_separate Thread.new do start.pop capture_command( - "payload = 'x' * (64 * 1024 * 1024); deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.5; loop { break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline }; print payload.bytesize" + "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 } @@ -156,10 +198,10 @@ def test_concurrent_captures_keep_each_child_resource_usage_separate 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.2 + 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 + 32 * 1024 * 1024 + 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 @@ -193,7 +235,8 @@ def test_reaped_child_metrics_survive_timeout_draining_pipes_held_by_escaped_des 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, :>, result.elapsed_seconds + 0.4, 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 @@ -203,23 +246,24 @@ def test_reaped_child_metrics_survive_timeout_draining_pipes_held_by_escaped_des 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 - ticks = Queue.new + begin_wait = Queue.new + ticked = Queue.new ticker = Thread.new do ready << true - loop do - sleep 0.01 - ticks << true - end + 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 - assert_operator ticks.size, :>=, 10 + refute_predicate ticked, :empty? ensure ticker&.kill ticker&.join