Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.

## Unreleased

### Fixed

- Remove the 100 ms child-exit polling interval from subprocess capture. Capture now drains stdout and stderr, then monitors the child via pidfd until exit or the wall-clock deadline, retaining polling only as a compatibility fallback when pidfds are unavailable.

## [0.4] - 2026-08-10

### Changed
Expand Down
15 changes: 15 additions & 0 deletions ext/landlock/landlock.c
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,20 @@ static VALUE rb_ll_close_fd(VALUE self, VALUE fd_value) {
return Qnil;
}

static VALUE rb_ll_pidfd_open(VALUE self, VALUE pid_value) {
#ifdef SYS_pidfd_open
int fd = syscall(SYS_pidfd_open, NUM2PIDT(pid_value), 0);
if (fd < 0) {
raise_syscall_error("pidfd_open");
}
return INT2NUM(fd);
#else
errno = ENOSYS;
raise_syscall_error("pidfd_open");
return Qnil;
#endif
}

static VALUE rb_ll_seccomp_deny_network(VALUE self) {
const char *error_message = "seccomp(SECCOMP_SET_MODE_FILTER)";
if (rb_landlock_seccomp_deny_network(&error_message) != 0) {
Expand Down Expand Up @@ -150,6 +164,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, "_pidfd_open", rb_ll_pidfd_open, 1);
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));
Expand Down
4 changes: 4 additions & 0 deletions ext/landlock/landlock_native.h
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
#endif
#endif

#if defined(__linux__) && !defined(SYS_pidfd_open) && defined(__NR_pidfd_open)
#define SYS_pidfd_open __NR_pidfd_open
#endif

#ifndef LANDLOCK_CREATE_RULESET_VERSION
#define LANDLOCK_CREATE_RULESET_VERSION (1U << 0)
#endif
Expand Down
4 changes: 4 additions & 0 deletions lib/landlock/native.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ def close_fd(fd)
Landlock.__send__(:_close_fd, fd)
end

def pidfd_open(pid)
Landlock.__send__(:_pidfd_open, pid)
end

def seccomp_deny_network!
Landlock.seccomp_deny_network!
end
Expand Down
116 changes: 79 additions & 37 deletions lib/landlock/process_io.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

module Landlock
READ_CHUNK_BYTES = 16 * 1024
PROCESS_POLL_SECONDS = 0.1
PID_WAIT_FALLBACK_INTERVAL_SECONDS = 0.1
STDIN_THREAD_JOIN_SECONDS = 0.1
POST_TIMEOUT_DRAIN_SECONDS = 0.05

Expand Down Expand Up @@ -91,45 +91,16 @@ def read_and_wait(pid, streams, timeout, max_output_bytes, truncate_output, stat
timed_out = false
status = nil

until streams.empty? && status
until streams.empty?
if deadline
remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
if remaining <= 0
timed_out = true
terminate_process(pid)
status = wait_for_pid(pid)
drain_streams_until(
streams,
::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + POST_TIMEOUT_DRAIN_SECONDS,
max_output_bytes,
truncate_output,
state,
pid
)
close_streams(streams)
break
end
end

status ||= poll_pid(pid)

break if streams.empty? && status

wait =
(
if deadline
[deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC), 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)
readable, = IO.select(streams.keys, nil, nil, remaining)
next unless readable

readable.each do |io|
Expand All @@ -145,16 +116,81 @@ def read_and_wait(pid, streams, timeout, max_output_bytes, truncate_output, stat
end
end

status ||= wait_for_pid(pid)
if deadline
status, timed_out = wait_for_pid_until(pid, deadline:)
else
status = wait_for_pid(pid)
end

if timed_out
drain_streams_until(
streams,
::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + POST_TIMEOUT_DRAIN_SECONDS,
max_output_bytes,
truncate_output,
state,
pid
)
close_streams(streams)
end

[status, timed_out]
end

def poll_pid(pid)
result = ::Process.wait2(pid, ::Process::WNOHANG)
result&.last
def wait_for_pid_until(pid, deadline:)
remaining = deadline - monotonic_time
if remaining <= 0
terminate_process(pid)
return wait_for_pid(pid), true
end

pidfd = Native.pidfd_open(pid)
pid_monitor = IO.for_fd(pidfd, autoclose: false)
readable, = IO.select([pid_monitor], nil, nil, remaining)
if !readable || monotonic_time >= deadline
terminate_process(pid)
return wait_for_pid(pid), true
end

[wait_for_pid(pid), false]
rescue Landlock::SyscallError
wait_for_pid_until_by_polling(pid, deadline:)
ensure
close_stream(pid_monitor) if pid_monitor
Native.close_fd(pidfd) if pidfd
end
private_class_method :wait_for_pid_until

def wait_for_pid_until_by_polling(pid, deadline:)
loop do
remaining = deadline - monotonic_time
if remaining <= 0
terminate_process(pid)
return wait_for_pid(pid), true
end

result = ::Process.wait2(pid, ::Process::WNOHANG)
if result
status = result.last
if monotonic_time >= deadline
terminate_process_group(pid)
return status, true
end

return status, false
end

IO.select(nil, nil, nil, [remaining, PID_WAIT_FALLBACK_INTERVAL_SECONDS].min)
end
rescue Errno::ECHILD
nil
[nil, false]
end
private_class_method :wait_for_pid_until_by_polling

def monotonic_time
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
end
private_class_method :monotonic_time

def wait_for_pid(pid)
::Process.wait2(pid).last
Expand Down Expand Up @@ -237,6 +273,12 @@ def terminate_process(pid)
signal_process("KILL", pid)
end

def terminate_process_group(pid)
::Process.kill("KILL", -pid)
rescue Errno::ESRCH, Errno::EPERM
end
private_class_method :terminate_process_group

def signal_process(signal, pid)
::Process.kill(signal, -pid)
rescue Errno::ESRCH, Errno::EPERM
Expand Down
Loading
Loading