2
0
mirror of https://github.com/xcat2/xcat-dep.git synced 2026-09-30 14:55:17 +00:00

feat(xcat-dep): add XCAT::NFSLock, a lock for the shared NFS tree

The dep builds lock trees on a shared NFS tree that the hypervisors
re-export. The kernel refuses flock and fcntl locks to clients of a
re-export, so every such lock fails with errno 524.

XCAT::NFSLock builds a lock as a directory under a private name, with an
owner record and the caller's metadata, and renames it into place. The
record holds the machine id, boot id, pid, process start time and a
random token. The owner removes the lock, or a process on the owner's
host that proves the owner dead: another boot, no such pid, or a pid
with another start time. That process takes a breaker lock and proves
the owner dead again before it removes the lock. Removal renames the
lock away first. Any other acquire waits up to a timeout, retrying every
3 s by default, then fails with the mv command that moves the lock away.
A lock left by mockbuild-all.pl before this module is taken when its
pid is gone on this host.

t/nfslock.t covers each rule without NFS.

Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com>
This commit is contained in:
Daniel Hilst
2026-09-26 08:39:49 -03:00
parent fc5c6d0375
commit e2bc5493de
2 changed files with 687 additions and 0 deletions
+406
View File
@@ -0,0 +1,406 @@
package XCAT::NFSLock;
# A lock on a shared, possibly re-exported, NFS tree.
#
# Definitions
# L, P, H, J locks, processes, hosts, jobs
# host : P → H the host a process runs on
# job : P → J the job a process belongs to
# Dₜ ⊆ P the processes dead at time t
# Oₜ(l) ⊆ P the processes that acquired l and have not released it, live or dead
#
# Assumptions
# A1 Job-host affinity ∀ p, q ∈ P : job(p) = job(q) ⇒ host(p) = host(q)
# A job always runs on the same host.
# A2 Mortality ∀ p ∈ P : ∃ t : p ∈ Dₜ
# Every process ends.
# A3 Atomicity mkdir and rename are atomic. flock and fcntl are unavailable.
#
# Safety
# S1 Single ownership ∀ l, t : |Oₜ(l) ∖ Dₜ| ≤ 1
# At most one live process holds a lock.
# S2 No borrowing q removes l at t ∧ q ∉ Oₜ(l) ⇒ ∀ p ∈ Oₜ(l) : p ∈ Dₜ ∧ host(p) = host(q)
# Only a process on the holders' host removes a lock, when all are dead.
#
# Liveness
# L1 Bounded wait Every acquire ends within its timeout, owning l or naming it in an error.
# L2 Recovery ∅ ≠ Oₜ(l) ⊆ Dₜ ∧ job(q) ∈ job(Oₜ(l)) ⇒ q takes l with no operator
# The next run of the same job reclaims a dead owner's lock.
# L3 Disjoint progress Jobs that write disjoint resources take disjoint locks.
#
# Algorithm
# acquire(l, τ)
# 1. r ← (host, boot, pid, start time) of this process.
# 2. Create a private directory holding r and rename it to l. On success, return.
# 3. If l's owner is dead, break(l) and go to 2.
# 4. If τ has passed, fail and name l.
# 5. Wait, then go to 2.
#
# break(l)
# 1. Create l.break atomically. If it exists, another process is breaking l: return.
# 2. If l's owner is still dead, rename l away.
# 3. Remove l.break.
#
# release(l)
# 1. If l's record is r, rename l away.
#
# dead(r) ⇔ r.host = this host
# ∧ (r.boot ≠ current boot ∨ r.pid does not exist ∨ start time of r.pid ≠ r.start)
#
# Where each property rests
# S1 acquire 2 (one rename wins) and break 2 (the owner is re-checked under l.break)
# S2 dead(r), which only r's host can prove, and release 1
# L1 acquire 4
# L2 acquire 3
# L3 the caller, which takes one lock per resource
use strict;
use warnings;
use Cwd qw(abs_path);
use Errno qw(EEXIST ENOENT ENOTEMPTY ESTALE);
use Exporter 'import';
use File::Basename qw(dirname basename);
use File::Glob qw(bsd_glob);
use File::Path qw(remove_tree);
use Sys::Hostname qw(hostname);
use Time::HiRes ();
our @EXPORT_OK = qw(owner_record parse_owner owner_is_dead process_start);
my $FORMAT = 'nfslock2';
#--------------------------------------------------------------------------------
=head3 acquire
Descriptions:
Take the lock at $path. Waits while another live process owns it, and
removes it when the owner is provably dead on this machine.
Arguments:
$path: path of the lock directory
%opt:
timeout => seconds to wait for a live owner (default 0: try once)
label => word for messages (default "lock")
retry => seconds between attempts, more than 0.5 (default 3)
meta => hash ref of file name => content, written into the lock
before it appears
Returns:
A lock object. Dies when the wait ends, naming the owner and the mv
command that moves the lock away. The next acquire deletes a lock moved
to <path>.dead.*.
=cut
#--------------------------------------------------------------------------------
sub acquire {
my ($class, $path, %opt) = @_;
my $timeout = $opt{timeout} // 0;
my $label = $opt{label} // 'lock';
my $retry = $opt{retry} // 3;
# Each attempt is a mkdir, writes and a rename on the NFS server.
die "Invalid retry interval $retry for $label: must be more than 0.5s\n" unless $retry > 0.5;
my $meta = $opt{meta} // {};
for my $name (keys %$meta) {
die "Invalid metadata name '$name' for $label\n"
if $name eq 'owner' || $name !~ /\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/;
}
# The last component names the lock itself. basename would turn '' into './' and drop a
# trailing slash, so the raw path is checked.
my ($name) = ($path // '') =~ m{(?:\A|/)([^/]+)\z};
die "Invalid $label path '" . ($path // '') . "'\n"
if !defined($name) || $name eq '.' || $name eq '..';
my $abs = _absolute($path);
my $self = bless { path => $abs, label => $label, pid => $$ }, $class;
$self->{record} = owner_record();
my $deadline = Time::HiRes::time() + $timeout;
my $owner;
_sweep($abs);
while (1) {
return $self if _create($abs, $self->{record}, $meta);
my $error = $!;
die "Cannot create $label $abs: $error\n"
unless grep { $error == $_ } (EEXIST, ENOTEMPTY, ENOENT, ESTALE);
my $current = _read_owner($abs);
$owner = $current if defined($current);
next if _owner_dead($current) && _break($abs);
my $left = $deadline - Time::HiRes::time();
last if $left <= 0;
# Randomise the wait. Two waiters that back off by the same amount keep colliding.
my $wait = $retry + rand($retry / 4);
Time::HiRes::sleep($wait < $left ? $wait : $left);
}
my $who = _describe(parse_owner($owner));
die "Trying to unlock $abs failed after ${timeout}s; $label owned by $who.\n"
. "If you are sure it is safe, move the lock away: mv $abs $abs.dead.manual\n";
}
#--------------------------------------------------------------------------------
=head3 release
Descriptions:
Remove the lock if this process owns it. A forked child of the owner does
nothing, and a lock that no longer carries this owner's record is left
alone.
Arguments:
none
Returns:
1 when the lock was removed, 0 otherwise.
=cut
#--------------------------------------------------------------------------------
sub release {
my ($self) = @_;
return 0 if $self->{released} || $$ != $self->{pid};
my $current = _read_owner($self->{path});
return 0 unless defined($current) && $current eq $self->{record};
$self->{released} = 1;
return _remove($self->{path});
}
sub path { return $_[0]{path} }
#--------------------------------------------------------------------------------
=head3 owner_record
Descriptions:
The content of the owner file: the machine, its boot, the pid and the
start time of the process, a random token, and, for messages only, the
host name and the creation time. The token tells two acquisitions of one
process apart, so a record names one acquisition.
Arguments:
none
Returns:
The record string.
=cut
#--------------------------------------------------------------------------------
sub owner_record {
my %here = %{ _here() };
return join('', map { "$_\n" } $FORMAT,
"machine=$here{machine}", "boot=$here{boot}", "pid=$$",
'start=' . (process_start($$) // 0),
sprintf('token=%08x%08x', int(rand(2**32)), int(rand(2**32))),
'host=' . (hostname() || 'unknown'), 'created=' . time());
}
#--------------------------------------------------------------------------------
=head3 parse_owner
Descriptions:
Split a record written by owner_record into its fields.
Arguments:
$record: the content of an owner file
Returns:
A hash ref of the fields, or undef when $record is not such a record.
=cut
#--------------------------------------------------------------------------------
sub parse_owner {
my ($record) = @_;
return undef unless defined($record);
my ($format, @lines) = split(/\n/, $record);
return undef unless defined($format) && $format eq $FORMAT;
my %field;
for my $line (@lines) {
my ($key, $value) = split(/=/, $line, 2);
return undef unless defined($value);
$field{$key} = $value;
}
for my $key (qw(machine boot pid start)) {
return undef unless defined($field{$key}) && length($field{$key});
}
return undef unless $field{pid} =~ /\A[1-9][0-9]*\z/ && $field{start} =~ /\A[0-9]+\z/;
return \%field;
}
#--------------------------------------------------------------------------------
=head3 owner_is_dead
Descriptions:
Decide from facts alone whether a recorded owner is dead. Only the owner's
machine can know: any other machine answers "not proven". An unreadable
record is never proven dead.
Arguments:
$owner: a hash ref from parse_owner, or undef
$here: hash ref with machine, boot and start_of, a code ref that returns
the start time of a pid on this machine, or undef when no such
process exists
Returns:
1 when the owner is provably dead, 0 otherwise.
=cut
#--------------------------------------------------------------------------------
sub owner_is_dead {
my ($owner, $here) = @_;
return 0 unless defined($owner);
return 0 unless $owner->{machine} eq $here->{machine};
return 1 unless $owner->{boot} eq $here->{boot};
my $start = $here->{start_of}->($owner->{pid});
return 1 unless defined($start);
return $start eq $owner->{start} ? 0 : 1;
}
#--------------------------------------------------------------------------------
=head3 process_start
Descriptions:
The start time of a process, field 22 of /proc/<pid>/stat, in clock ticks
since boot. A reused pid has a later start time.
Arguments:
$pid: the process id
Returns:
The start time, or undef when no such process exists.
=cut
#--------------------------------------------------------------------------------
sub process_start {
my ($pid) = @_;
open(my $fh, '<', "/proc/$pid/stat") or return undef;
my $stat = <$fh>;
close($fh);
return undef unless defined($stat);
# The command name, field 2, may contain spaces and parentheses.
$stat =~ s/\A.*\)\s+//s or return undef;
my @field = split(/\s+/, $stat);
return $field[19];
}
# Build the lock under a private name, then rename it into place: the owner
# record and the metadata appear with the lock. rename fails when the target is
# a directory that is not empty, so one creator wins.
sub _create {
my ($path, $record, $meta) = @_;
my $tmp = _private_name($path, 'tmp');
mkdir($tmp) or return 0;
my %files = (%{ $meta // {} }, owner => $record);
for my $name (sort keys %files) {
my $fh;
unless (open($fh, '>', "$tmp/$name") && print({$fh} $files{$name}) && close($fh)) {
my $error = $!;
remove_tree($tmp);
die "Cannot write $tmp/$name: $error\n";
}
}
return 1 if rename($tmp, $path);
my $error = $!;
remove_tree($tmp);
# NFS can retransmit a rename that already succeeded, and the reply is then
# an error. The owner file says whether the lock in place is this one.
my $current = _read_owner($path);
return 1 if defined($current) && $current eq $record;
$! = $error;
return 0;
}
# Two processes can find the same dead owner. Only the one holding the breaker
# removes the lock, and only after it proves the current owner dead again: only
# the owner or the breaker removes the lock, so that owner is the one removed.
sub _break {
my ($path) = @_;
my $breaker = "$path.break";
my $mine = owner_record();
return 0 unless _create($breaker, $mine, {});
my $removed = 0;
if (_owner_dead(_read_owner($path))) {
$removed = _remove($path);
}
my $held = _read_owner($breaker);
_remove($breaker) if defined($held) && $held eq $mine;
return $removed;
}
# One rename takes the lock away. The detached tree is deleted afterwards: an
# NFS client keeps a file open by renaming it to .nfsXXXX, which only delays
# that delete.
sub _remove {
my ($path) = @_;
my $dead = _private_name($path, 'dead');
return 0 unless rename($path, $dead);
remove_tree($dead);
return 1;
}
# Leftovers of earlier runs. A detached tree belongs to nobody. A private build
# belongs to its creator until that creator is proven dead.
sub _sweep {
my ($path) = @_;
for my $dead (bsd_glob("$path.dead.*")) {
remove_tree($dead) if -d $dead && !-l $dead;
}
for my $tmp (bsd_glob("$path.tmp.*")) {
next unless -d $tmp && !-l $tmp;
remove_tree($tmp) if _owner_dead(_read_owner($tmp));
}
}
# Whether the owner file names a dead owner. mockbuild-all.pl wrote host, pid and epoch before this
# module, without a start time: there, only a pid that no longer exists on this host is proof.
sub _owner_dead {
my ($record) = @_;
return owner_is_dead(parse_owner($record), _here()) if defined(parse_owner($record));
return 0 unless defined($record) && $record =~ /\Ahost=(\S+)\npid=([1-9][0-9]*)\nepoch=[0-9]+\n\z/;
my ($host, $pid) = ($1, $2);
return 0 unless $host eq (hostname() || '');
return defined(process_start($pid)) ? 0 : 1;
}
sub _private_name {
my ($path, $kind) = @_;
return sprintf('%s.%s.%d.%08x', $path, $kind, $$, int(rand(2**32)));
}
sub _read_owner {
my ($path) = @_;
open(my $fh, '<', "$path/owner") or return undef;
local $/;
my $record = <$fh>;
close($fh);
return $record;
}
sub _here {
return {
machine => _first_line('/etc/machine-id') // (hostname() || 'unknown'),
boot => _first_line('/proc/sys/kernel/random/boot_id') // 'unknown',
start_of => \&process_start,
};
}
sub _first_line {
my ($file) = @_;
open(my $fh, '<', $file) or return undef;
my $line = <$fh>;
close($fh);
return undef unless defined($line);
chomp($line);
return length($line) ? $line : undef;
}
sub _describe {
my ($owner) = @_;
return 'an unknown owner' unless defined($owner);
return sprintf('pid %s on %s since %s', $owner->{pid}, $owner->{host} // $owner->{machine},
scalar(localtime($owner->{created} // 0)));
}
# An absolute path in the message, without resolving the lock itself.
sub _absolute {
my ($path) = @_;
my $dir = abs_path(dirname($path)) // dirname($path);
return "$dir/" . basename($path);
}
1;
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/perl
# XCAT::NFSLock: a directory lock that only its owner, or a process that proves
# the owner dead on the owner's machine, removes.
use strict;
use warnings;
use Test::More;
use FindBin qw($RealBin);
use lib "$RealBin/../lib";
use File::Temp qw(tempdir);
use File::Slurper qw(read_text write_text);
use Errno qw(ENOENT);
use POSIX ();
use Time::HiRes ();
my $retransmit;
BEGIN {
no warnings 'once';
# An NFS retransmit: the first rename succeeded, the reply said ENOENT.
*CORE::GLOBAL::rename = sub {
my ($from, $to) = @_;
if (defined($retransmit) && $to eq $retransmit) {
CORE::rename($from, $to) or die "Cannot stage $to: $!";
$! = ENOENT;
return 0;
}
return CORE::rename($from, $to);
};
}
use XCAT::NFSLock qw(owner_record parse_owner owner_is_dead process_start);
my $dir = tempdir(CLEANUP => 1);
my $me = parse_owner(owner_record());
ok($me, 'the owner record of this process parses');
is($me->{pid}, $$, 'the owner record names this process');
is($me->{start}, process_start($$), 'the owner record carries the start time of this process');
# No process can have a pid above the kernel's pid_max (2**22 at most).
my $gone = 2**22 + 7;
is(process_start($gone), undef, 'a pid that names no process has no start time');
my $parent = getppid();
my $parent_start = process_start($parent);
sub record {
my (%f) = @_;
my %r = (%$me, host => 'peer', created => 1, %f);
return join('', map { "$_\n" } 'nfslock2', map { "$_=$r{$_}" } qw(machine boot pid start token host created));
}
sub stage {
my ($name, $record) = @_;
my $path = "$dir/$name";
mkdir($path) or die "Cannot stage $path: $!";
write_text("$path/owner", $record) if defined($record);
return $path;
}
sub leftovers {
my ($path) = @_;
return [ map { s{\A\Q$dir\E/}{}r } glob("$path.*") ];
}
# A free lock is taken with its metadata and released without leftovers.
{
my $path = "$dir/free.lock";
my $lock = XCAT::NFSLock->acquire($path, meta => { job => "xcat-dep-build-el10\n" });
is(read_text("$path/owner"), $lock->{record}, 'a free lock is taken with this owner record');
is(read_text("$path/job"), "xcat-dep-build-el10\n", 'the metadata is in the lock');
is($lock->release, 1, 'the owner releases its lock');
ok(!-e $path, 'the released lock is gone');
is_deeply(leftovers($path), [], 'release leaves nothing beside the lock');
is($lock->release, 0, 'a second release does nothing');
}
for my $bad ('', "$dir/.", "$dir/..", "$dir/") {
eval { XCAT::NFSLock->acquire($bad); 1 };
like($@, qr/\AInvalid lock path/, "a lock path that names no entry is refused: '$bad'");
}
for my $retry (0, 0.5, -1) {
eval { XCAT::NFSLock->acquire("$dir/bad-retry.lock", retry => $retry); 1 };
like($@, qr/\AInvalid retry interval $retry for lock: must be more than 0\.5s/,
"a retry interval of ${retry}s is refused");
}
# A waiter takes the lock once its live owner releases it.
{
my $path = "$dir/handover.lock";
pipe(my $ready_r, my $ready_w) or die "Cannot pipe: $!";
my $child = fork() // die "Cannot fork: $!";
if ($child == 0) {
close($ready_r);
my $held = XCAT::NFSLock->acquire($path);
syswrite($ready_w, "x");
Time::HiRes::sleep(0.5);
$held->release;
POSIX::_exit(0);
}
close($ready_w);
sysread($ready_r, my $byte, 1);
my $start = Time::HiRes::time();
my $lock = eval { XCAT::NFSLock->acquire($path, timeout => 10, retry => 0.6) };
my $spent = Time::HiRes::time() - $start;
waitpid($child, 0);
ok($lock, 'a waiter takes the lock after its owner releases it') or diag($@);
cmp_ok($spent, '<', 3, 'the waiter retries at its interval, not at the timeout');
$lock->release if $lock;
}
eval { XCAT::NFSLock->acquire("$dir/bad-meta.lock", meta => { owner => 'x' }); 1 };
like($@, qr/\AInvalid metadata name 'owner'/, 'metadata cannot replace the owner record');
eval { XCAT::NFSLock->acquire("$dir/bad-meta.lock", meta => { '../x' => 'x' }); 1 };
like($@, qr/\AInvalid metadata name '\.\.\/x'/, 'metadata names stay inside the lock');
# Live owners and owners that cannot be proven dead keep their lock.
for my $case (
[ 'live owner on this machine', record(pid => $parent, start => $parent_start) ],
[ 'owner on another machine', record(machine => 'elsewhere', pid => $gone) ],
[ 'record in another format', "somebody-else\n" ],
[ 'lock with no owner file', undef ],
)
{
my ($name, $record) = @$case;
(my $file = "$name.lock") =~ s/\s+/-/g;
my $path = stage($file, $record);
write_text("$path/data", 'kept');
eval { XCAT::NFSLock->acquire($path, timeout => 0.3, label => 'repository lock'); 1 };
like($@, qr/\ATrying to unlock \Q$path\E failed after 0\.3s; repository lock owned by /,
"$name: the wait ends with an error that names the lock");
is(read_text("$path/data"), 'kept', "$name: the lock is left in place");
is_deeply(leftovers($path), [], "$name: no breaker or private tree is left behind");
}
# Owners proven dead on this machine lose their lock.
for my $case (
[ 'process gone', record(pid => $gone) ],
[ 'pid reused', record(pid => $parent, start => $parent_start + 1) ],
[ 'machine rebooted', record(boot => 'an-earlier-boot', pid => $parent, start => $parent_start) ],
)
{
my ($name, $record) = @$case;
(my $file = "$name.lock") =~ s/\s+/-/g;
my $path = stage($file, $record);
my $lock = eval { XCAT::NFSLock->acquire($path) };
ok($lock, "$name: the lock of a dead owner is taken") or diag($@);
is(read_text("$path/owner"), $lock && $lock->{record}, "$name: the lock now names this process");
is_deeply(leftovers($path), [], "$name: the breaker and the old lock are gone");
$lock->release if $lock;
}
# Locks written by mockbuild-all.pl before XCAT::NFSLock: host, pid and epoch only.
{
use Sys::Hostname qw(hostname);
my $host = hostname();
my $old = sub { my ($h, $pid) = @_; return "host=$h\npid=$pid\nepoch=1\n" };
my $gone_path = stage('legacy-gone.lock', $old->($host, $gone));
my $lock = eval { XCAT::NFSLock->acquire($gone_path) };
ok($lock, 'an old lock whose process is gone on this host is taken') or diag($@);
$lock->release if $lock;
for my $case ([ 'live on this host', $old->($host, $parent) ],
[ 'from another host', $old->('another-host', $gone) ]) {
my ($name, $record) = @$case;
(my $file = "legacy-$name.lock") =~ s/\s+/-/g;
my $path = stage($file, $record);
eval { XCAT::NFSLock->acquire($path); 1 };
like($@, qr/\ATrying to unlock \Q$path\E /, "an old lock $name is not taken");
is(read_text("$path/owner"), $record, "the old lock $name is left in place");
}
}
# Another process is breaking the lock: this one does not remove it.
{
my $dead = record(pid => $gone);
my $path = stage('breaking.lock', $dead);
my $busy = record(pid => $parent, start => $parent_start);
stage('breaking.lock.break', $busy);
eval { XCAT::NFSLock->acquire($path, timeout => 0.3); 1 };
like($@, qr/\ATrying to unlock /, 'a lock under another breaker is not taken');
is(read_text("$path/owner"), $dead, 'the lock under another breaker is left in place');
is(read_text("$path.break/owner"), $busy, 'the other breaker is left in place');
}
# The owner is alive by the time of the break: the breaker does not remove the lock.
{
my $live = record(pid => $parent, start => $parent_start);
my $path = stage('changed.lock', $live);
is(XCAT::NFSLock::_break($path), 0, 'a lock whose owner is alive at the break is not removed');
is(read_text("$path/owner"), $live, 'the owner keeps its lock');
is_deeply(leftovers($path), [], 'the breaker is released');
}
# A forked child of the owner does not release the lock.
{
my $path = "$dir/forked.lock";
my $lock = XCAT::NFSLock->acquire($path);
my $child = fork() // die "Cannot fork: $!";
POSIX::_exit($lock->release ? 1 : 0) if $child == 0;
waitpid($child, 0);
is($? >> 8, 0, 'the child reports that it released nothing');
ok(-d $path, 'the lock survives the child');
is($lock->release, 1, 'the owner still releases it');
}
# A delete that NFS holds back (a file still open is renamed to .nfsXXXX and
# stays) does not keep the lock: release takes it away first.
{
my $path = "$dir/held-open.lock";
my $lock = XCAT::NFSLock->acquire($path);
{
no warnings 'redefine';
local *XCAT::NFSLock::remove_tree = sub { return 0 };
is($lock->release, 1, 'release succeeds while the delete is held back');
}
ok(!-e $path, 'the lock is gone while its old tree still exists');
my $next = eval { XCAT::NFSLock->acquire($path) };
ok($next, 'the next owner takes the lock at once') or diag($@);
$next->release if $next;
}
# One process takes the lock once: a second acquire is another owner.
{
my $path = "$dir/twice.lock";
my $first = XCAT::NFSLock->acquire($path);
my $second = eval { XCAT::NFSLock->acquire($path) };
ok(!$second, 'a second acquire in the same process does not take a held lock');
is($first->release, 1, 'the first owner still holds and releases it');
}
# The lock was replaced: release leaves the other owner's lock alone.
{
my $path = "$dir/replaced.lock";
my $lock = XCAT::NFSLock->acquire($path);
my $other = record(pid => $parent, start => $parent_start);
write_text("$path/owner", $other);
is($lock->release, 0, 'release does not remove a lock with another record');
is(read_text("$path/owner"), $other, 'the other owner keeps its lock');
}
# NFS answered with an error to a rename that had succeeded.
{
my $path = $retransmit = "$dir/retransmit.lock";
my $lock = eval { XCAT::NFSLock->acquire($path) };
ok($lock, 'a retransmitted rename that placed this record takes the lock') or diag($@);
undef $retransmit;
}
# Leftovers: detached trees go, a private build goes only when its creator is dead.
{
my $path = "$dir/swept.lock";
stage('swept.lock.dead.1.00000001', record(pid => $parent, start => $parent_start));
stage('swept.lock.tmp.2.00000002', record(pid => $gone));
stage('swept.lock.tmp.3.00000003', record(pid => $parent, start => $parent_start));
my $lock = XCAT::NFSLock->acquire($path);
is_deeply(leftovers($path), ['swept.lock.tmp.3.00000003'],
'the sweep removes detached trees and dead builds, and keeps a live build');
$lock->release;
}
# owner_is_dead decides from facts only.
{
my %here = (machine => 'm1', boot => 'b1', start_of => sub { $_[0] == 10 ? 100 : undef });
my %rec = (machine => 'm1', boot => 'b1', pid => 10, start => 100);
is(owner_is_dead(undef, \%here), 0, 'an unreadable record is not proven dead');
is(owner_is_dead({ %rec }, \%here), 0, 'a live owner is not dead');
is(owner_is_dead({ %rec, machine => 'm2' }, \%here), 0, 'another machine proves nothing');
is(owner_is_dead({ %rec, machine => 'm2', pid => 11 }, \%here), 0,
'another machine proves nothing, even for a pid that is free here');
is(owner_is_dead({ %rec, boot => 'b0' }, \%here), 1, 'an owner from an earlier boot is dead');
is(owner_is_dead({ %rec, pid => 11 }, \%here), 1, 'an owner whose pid is gone is dead');
is(owner_is_dead({ %rec, start => 99 }, \%here), 1, 'an owner whose pid was reused is dead');
}
is(parse_owner("nfslock2\nmachine=m\nboot=b\npid=0\nstart=1\n"), undef, 'a record with pid 0 is rejected');
is(parse_owner("nfslock2\nmachine=m\nboot=b\npid=5\n"), undef, 'a record without a start time is rejected');
is(parse_owner("nfslock1\nmachine=m\nboot=b\npid=5\nstart=1\n"), undef, 'a record in another format is rejected');
done_testing();