2
0
mirror of https://github.com/xcat2/xcat-dep.git synced 2026-09-09 22:46:44 +00:00
Files
xcat-dep/lib/XCAT/BuildUtils.pm
T
Vinícius Ferrão 754567ec6c fix(xcat-dep): clear the build process group when its leader dies
run_bounded signalled the process group on a timeout and on a cancellation, but
not when the leader itself died. A SIGKILL, or the OOM killer, takes the shell
and leaves schroot and qemu in the group, holding the chroot and writing into
staging after the call reports the build finished.

Those processes are not children of this one, so there is nothing to wait for.
Signal the group on the way out.
2026-09-06 15:55:23 -03:00

450 lines
16 KiB
Perl

package XCAT::BuildUtils;
use strict;
use warnings;
use Digest::MD5 ();
use Digest::SHA ();
use Exporter qw(import);
use File::Find qw(find);
use File::Slurper qw(read_binary write_binary);
use File::Spec;
use IPC::Cmd qw(can_run);
use POSIX ();
use Time::HiRes ();
our @EXPORT_OK = qw(
capture_command
command_exists
digest_file
digest_manifest
display_quote
every_step_failed
forward_signals_to_workers
block_handled_signals
exit_status
restore_signal_mask
hashes_equal
print_step
read_binary
read_first_line
read_lines
relative_files
require_command
run_bounded
run_command
shell_quote
stall_report
emulated_build_timeout
write_binary
);
sub command_exists {
my ($command) = @_;
return defined(can_run($command));
}
sub require_command {
my ($command) = @_;
return can_run($command)
// die "Required command not found: $command\n";
}
sub capture_command {
my (@command) = @_;
open(my $fh, '-|', @command) or die "Cannot run $command[0]: $!\n";
local $/;
my $output = <$fh> // '';
close($fh) or die "Command failed: $command[0]\n";
$output =~ s/\s+\z//;
return $output;
}
sub run_command {
my (@command) = @_;
print '+ ', join(' ', map { display_quote($_) } @command), "\n";
my $status = system(@command);
return 1 if $status == 0;
my $exit = $status == -1
? 255
: ($status & 127) ? 128 + ($status & 127) : $status >> 8;
die "Command failed (rc=$exit): "
. join(' ', map { display_quote($_) } @command) . "\n";
}
# ---------------------------------------------------------------------------------------------------
# Bounded command execution (build steps that can run under emulation)
# ---------------------------------------------------------------------------------------------------
# NATIVE_BUILD_TIMEOUT: the per-package wall-clock budget for a build on the host architecture.
# EMULATION_FACTOR: qemu-user under TCG runs at roughly a tenth of native speed, so a foreign-arch
# target gets ten times the budget. Both numbers come from measured runs of this tree, not a guess:
# on xcat-master-ub the slowest native (amd64) package took 3 minutes, and the slowest emulated
# (riscv64) package -- ipmitool-xcat on resolute -- took 26 minutes with four codenames building at
# once. 900s and 9000s therefore sit about five times above the worst build ever measured, which is
# the margin that keeps a busy host from producing a false failure.
our $NATIVE_BUILD_TIMEOUT = 900;
our $EMULATION_FACTOR = 10;
# emulated_build_timeout($target_arch, $host_arch): the budget for a build of $target_arch on
# $host_arch. Equal arches are native. Any other pair is emulated through qemu-user.
sub emulated_build_timeout {
my ($target_arch, $host_arch) = @_;
return $NATIVE_BUILD_TIMEOUT
if !defined($target_arch) || !defined($host_arch) || $target_arch eq $host_arch;
return $NATIVE_BUILD_TIMEOUT * $EMULATION_FACTOR;
}
# proc_pgid_pids($pgid): every live pid in process group $pgid, read from /proc rather than matched
# against a command line. `pgrep -af X | grep -c Y` counts its own command line, and bracketing the
# pattern still self-matches when the surrounding command carries the bare word, so this code never
# matches text at all.
sub proc_pgid_pids {
my ($pgid) = @_;
my @pids;
opendir(my $dh, '/proc') or return ();
for my $e (sort { $a <=> $b } grep { /^\d+$/ } readdir($dh)) {
my $f = _proc_stat_fields($e) or next;
push @pids, $e if defined($f->{pgrp}) && $f->{pgrp} == $pgid;
}
closedir($dh);
return @pids;
}
# _proc_stat_fields($pid): the /proc/<pid>/stat fields this module reads. The comm field is wrapped
# in parentheses and may itself contain a space or a parenthesis, so the split starts after the LAST
# ')'.
sub _proc_stat_fields {
my ($pid) = @_;
open(my $fh, q{<}, "/proc/$pid/stat") or return;
my $line = <$fh>;
close($fh);
return unless defined $line;
my $close = rindex($line, ')');
return if $close < 0;
my $comm = substr($line, index($line, '(') + 1, $close - index($line, '(') - 1);
my @f = split(' ', substr($line, $close + 2));
return {
comm => $comm,
state => $f[0],
ppid => $f[1],
pgrp => $f[2],
ticks => (($f[11] // 0) + ($f[12] // 0)),
};
}
sub _proc_read {
my ($path) = @_;
open(my $fh, '<', $path) or return '';
local $/;
my $t = <$fh> // '';
close($fh);
$t =~ s/\0/ /g;
$t =~ s/\s+\z//;
return $t;
}
# _proc_socket_count($pid): how many of the pid's descriptors are sockets. A build that is waiting on
# the network holds one; the deadlock this bound exists for held none.
sub _proc_socket_count {
my ($pid) = @_;
opendir(my $dh, "/proc/$pid/fd") or return -1;
my $n = 0;
for my $fd (grep { /^\d+$/ } readdir($dh)) {
my $l = readlink("/proc/$pid/fd/$fd") // '';
$n++ if $l =~ /^socket:/;
}
closedir($dh);
return $n;
}
# stall_report($pgid, %opt): the evidence a hung build needs, gathered before the kill. For every pid
# in the process group it prints the parent, the state, the kernel wchan and stack, the socket count,
# and the CPU ticks the pid consumed across a sample window. The tick delta is what separates a
# deadlock from a slow build: a package that is merely slow keeps accumulating ticks, and the
# goconserver deadlock this bound exists for accumulated none over twenty seconds.
# %opt: sample (seconds, default 20), out (filehandle, default STDERR)
sub stall_report {
my ($pgid, %opt) = @_;
my $sample = defined $opt{sample} ? $opt{sample} : 20;
my $out = $opt{out} || \*STDERR;
my @pids = proc_pgid_pids($pgid);
my %before = map { $_ => (_proc_stat_fields($_) || {})->{ticks} } @pids;
sleep($sample) if $sample > 0;
my @after_pids = proc_pgid_pids($pgid);
my %seen = map { $_ => 1 } @pids;
push @pids, grep { !$seen{$_} } @after_pids;
my $total = 0;
print {$out} "--- stall report: process group $pgid, ${sample}s CPU sample ---\n";
printf {$out} "%-8s %-8s %-5s %-10s %-10s %-24s %s\n",
'PID', 'PPID', 'STAT', 'TICKS', 'DELTA', 'WCHAN', 'CMD';
for my $pid (@pids) {
my $f = _proc_stat_fields($pid) or next;
my $delta = ($f->{ticks} // 0) - ($before{$pid} // 0);
$delta = 0 if $delta < 0;
$total += $delta;
my $cmd = _proc_read("/proc/$pid/cmdline") || "[$f->{comm}]";
$cmd = substr($cmd, 0, 120);
printf {$out} "%-8s %-8s %-5s %-10s %-10s %-24s %s\n",
$pid, $f->{ppid}, $f->{state}, $f->{ticks}, $delta,
(_proc_read("/proc/$pid/wchan") || '?'), $cmd;
my $stack = _proc_read("/proc/$pid/stack");
print {$out} " stack: $_\n" for grep { length } split(/\n/, $stack);
my $socks = _proc_socket_count($pid);
print {$out} " sockets: $socks\n" if $socks >= 0;
}
print {$out} "--- CPU ticks consumed by the whole group during the sample: $total\n";
print {$out} $total == 0
? "--- no CPU ticks: the build is DEADLOCKED, not slow.\n"
: "--- the build still consumes CPU: it exceeded the budget rather than deadlocking.\n";
return $total;
}
# exit_status($status): the exit code of a waited-for child, or 128 plus the signal that killed it.
# A child killed by a signal has 0 in the high byte, so reading only that byte reports a build the
# kernel terminated as a build that succeeded.
sub exit_status {
my ($status) = @_;
return 0 unless defined $status;
return ($status & 127) ? 128 + ($status & 127) : $status >> 8;
}
# block_handled_signals(): block INT, TERM and HUP and return the previous mask, for the window
# between forking a child and being able to signal it. A cancellation arriving in that window would
# otherwise kill the parent under a handler that does not know the child yet, and the child would
# keep running. A blocked signal stays pending and is delivered by restore_signal_mask().
sub block_handled_signals {
my $handled = POSIX::SigSet->new(POSIX::SIGINT(), POSIX::SIGTERM(), POSIX::SIGHUP());
my $previous = POSIX::SigSet->new();
POSIX::sigprocmask(POSIX::SIG_BLOCK(), $handled, $previous);
return $previous;
}
# restore_signal_mask($previous): put the mask back, delivering anything that arrived meanwhile.
sub restore_signal_mask {
my ($previous) = @_;
POSIX::sigprocmask(POSIX::SIG_SETMASK(), $previous) if $previous;
return;
}
# forward_signals_to_workers(%a): return an INT/TERM/HUP handler that passes the signal on to the
# forked workers, waits for them, then re-raises it. An orchestrator that dies without this releases
# its locks while its workers keep building and writing into staging, and the next run races
# processes it cannot see. run_bounded covers the build inside ONE worker; this covers the workers.
# %a: pids (hashref keyed by live worker pid), reap (coderef that waits for them)
sub forward_signals_to_workers {
my (%a) = @_;
my $pids = $a{pids} or die "forward_signals_to_workers: missing 'pids'\n";
my $reap = $a{reap} or die "forward_signals_to_workers: missing 'reap'\n";
return sub {
my ($sig) = @_;
kill($sig, keys %{$pids});
$reap->();
# die by the same signal, so the caller's exit status says what happened
$SIG{$sig} = 'DEFAULT';
kill($sig, $$);
};
}
# run_bounded(%a): run a shell command with a wall-clock budget. On expiry it prints a stall report
# and kills the whole process group, so a build that deadlocks fails LOUDLY instead of hanging a
# pipeline forever -- an unbounded hang reads as "still running", never as a defect.
# The command runs in its own process group: a build spawns schroot, mock, qemu and make, and only a
# group signal reaches all of them. Signalling the group also means no child survives holding the
# caller's stdout open, which would turn the timeout back into a hang one level up.
# %a: cmd (required), timeout (seconds; <=0 runs without a deadline), label, sample, out
# Returns { ec, timed_out, elapsed }. ec is 124 on a timeout, matching timeout(1).
sub run_bounded {
my (%a) = @_;
my $cmd = defined $a{cmd} ? $a{cmd} : die "run_bounded: missing 'cmd'\n";
my $timeout = $a{timeout} || 0;
my $label = defined $a{label} ? $a{label} : 'command';
my $out = $a{out} || \*STDERR;
my $t0 = time;
# Block the handled signals across the fork. A cancellation landing between fork() and the
# handler below would kill this process under the inherited handler and leave the new process
# group running. A blocked signal stays pending and is delivered once the handler is in place.
# The child restores the mask before exec, or the build would inherit a blocked TERM.
my $previous = block_handled_signals();
my $pid = fork();
unless (defined $pid) {
restore_signal_mask($previous);
die "run_bounded: fork failed: $!\n";
}
if ($pid == 0) {
POSIX::setpgid(0, 0);
restore_signal_mask($previous);
exec('bash', '-c', $cmd) or POSIX::_exit(127);
}
# setpgid from BOTH sides: whichever runs first wins, so the group exists before the first signal
# whatever the scheduler does.
POSIX::setpgid($pid, $pid);
# A signal to this process must reach the build too. Without this the orchestrator dies and
# releases its locks while schroot, mock and qemu keep writing into staging, so the next run
# races an orphan it cannot see.
my $reap_group = sub {
kill('TERM', -$pid);
for (1 .. 40) {
last if waitpid($pid, POSIX::WNOHANG()) == $pid;
Time::HiRes::sleep(0.5);
}
kill('KILL', -$pid);
waitpid($pid, 0);
};
my $forward = sub {
my ($sig) = @_;
$reap_group->();
# die by the same signal, so the caller's exit status says what happened
$SIG{$sig} = 'DEFAULT';
kill($sig, $$);
};
local $SIG{INT} = $forward;
local $SIG{TERM} = $forward;
local $SIG{HUP} = $forward;
restore_signal_mask($previous);
# An unbounded run still forks: it is the process group, not the deadline, that lets a signal
# to the orchestrator reach the build.
my $deadline = $timeout > 0 ? $t0 + $timeout : undef;
my $timed_out = 0;
my $status;
while (1) {
my $r = waitpid($pid, POSIX::WNOHANG());
if ($r == $pid) { $status = $?; last; }
if ($r == -1) { $status = 0; last; }
if (defined $deadline and time >= $deadline) { $timed_out = 1; last; }
Time::HiRes::sleep(0.5);
}
if ($timed_out) {
my $el = time - $t0;
print {$out} "\nFATAL: $label exceeded its ${timeout}s budget (ran ${el}s).\n";
eval { stall_report($pid, sample => (defined $a{sample} ? $a{sample} : 20), out => $out); 1 }
or print {$out} "stall report failed: $@";
# Unconditional final reap: a child left unreaped keeps the group alive and the caller
# blocks on output that never ends.
$reap_group->();
return { ec => 124, timed_out => 1, elapsed => time - $t0 };
}
my $ec = exit_status($status);
# The leader can die without its descendants. A SIGKILL, or the OOM killer, takes the shell but
# leaves schroot and qemu in its process group, still holding the chroot and writing into
# staging after this call reports the build finished. They are not children of this process, so
# there is nothing to wait for: signal the group and move on.
kill('TERM', -$pid);
Time::HiRes::sleep(0.2);
kill('KILL', -$pid);
return { ec => $ec, timed_out => 0, elapsed => time - $t0 };
}
sub display_quote {
my ($value) = @_;
return $value if $value =~ /^[A-Za-z0-9_.,+\/:=@~-]+$/;
return shell_quote($value);
}
sub shell_quote {
my ($value) = @_;
$value = '' unless defined($value);
$value =~ s/'/'"'"'/g;
return "'$value'";
}
sub print_step {
my ($message) = @_;
print "\n== $message ==\n";
}
sub read_lines {
my ($path) = @_;
my $content = read_binary($path);
return () if $content eq '';
my @lines = split(/\n/, $content, -1);
pop(@lines) if @lines && $lines[-1] eq '';
s/\r\z// for @lines;
return @lines;
}
sub read_first_line {
my ($path) = @_;
my @lines = read_lines($path);
die "Empty file: $path\n" unless @lines;
return $lines[0];
}
sub relative_files {
my ($root) = @_;
die "Invalid directory: $root\n" unless -d $root && !-l $root;
my $absolute = File::Spec->rel2abs($root);
my @files;
find(
{
no_chdir => 1,
wanted => sub {
my $path = $File::Find::name;
return if $path eq $absolute;
die "Symbolic links are not allowed: $path\n" if -l $path;
return if -d $path;
die "Non-regular entry: $path\n" unless -f $path;
my $relative = File::Spec->abs2rel($path, $absolute);
$relative =~ tr{\\}{/};
push(@files, $relative);
},
},
$absolute,
);
my @sorted = sort @files;
return @sorted;
}
sub digest_file {
my ($path, $algorithm) = @_;
$algorithm //= 'sha256';
my $digest = $algorithm eq 'sha256' ? Digest::SHA->new(256)
: $algorithm eq 'md5' ? Digest::MD5->new
: die "Unsupported digest algorithm: $algorithm\n";
open(my $fh, '<:raw', $path) or die "Cannot read $path: $!\n";
my $value = $digest->addfile($fh)->hexdigest;
close($fh) or die "Cannot close $path: $!\n";
return $value;
}
sub digest_manifest {
my ($root, $algorithm, @files) = @_;
return join(
'',
map { digest_file("$root/$_", $algorithm) . " $_\n" }
sort @files,
);
}
# True when a set of steps was attempted and none of them survived. Callers tolerate
# individual failures; losing every step means the builder itself did not work.
sub every_step_failed {
my ($attempted, $failures) = @_;
return 0 unless $attempted;
return $failures >= $attempted ? 1 : 0;
}
sub hashes_equal {
my ($left, $right) = @_;
return 0 unless keys(%{$left}) == keys(%{$right});
for my $name (keys %{$left}) {
return 0 unless exists($right->{$name})
&& $left->{$name} eq $right->{$name};
}
return 1;
}
1;