public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Thomas Lamprecht <t.lamprecht@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH v3 storage 06/76] storage: bound LVM status commands
Date: Fri,  6 Nov 2026 00:30:41 +0100	[thread overview]
Message-ID: <20261105233336.2178064-7-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20261105233336.2178064-1-t.lamprecht@proxmox.com>

LVM can wait indefinitely for an unavailable device, stalling the
storage status pass. Bound its commands with a shared time budget
and retain an inherited lock so a stuck command prevents more probes
from accumulating. Killing a command does not guarantee that it
exits, so cleanup must not wait indefinitely either.

Give concurrent callers the same short, bounded lock wait, including
when a previous command remains stuck. Count that wait against the
budget and reject incomplete results. Retain pending children for
later reaping and hand their records over across daemon re-exec.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
This should likely become a generic helper in pve-common. Keep it here
to avoid adding another repository to this already cross-repository
series.

 src/PVE/Storage.pm                            |  13 +-
 src/PVE/Storage/LVMPlugin.pm                  |  56 +-
 src/PVE/Storage/LvmThinPlugin.pm              |  39 +-
 src/PVE/Storage/Makefile                      |   3 +-
 src/PVE/Storage/StatusCommand.pm              | 241 ++++++++
 src/test/Makefile                             |  12 +-
 src/test/run_san_library_tests.pl             |  35 ++
 .../run_status_command_contention_tests.pl    | 520 ++++++++++++++++++
 src/test/run_status_command_tests.pl          | 189 +++++++
 src/test/run_test_lvm_hints.pl                |   7 +-
 src/test/run_test_lvm_status.pl               | 220 ++++++++
 11 files changed, 1299 insertions(+), 36 deletions(-)
 create mode 100644 src/PVE/Storage/StatusCommand.pm
 create mode 100755 src/test/run_san_library_tests.pl
 create mode 100755 src/test/run_status_command_contention_tests.pl
 create mode 100755 src/test/run_status_command_tests.pl
 create mode 100755 src/test/run_test_lvm_status.pl

diff --git a/src/PVE/Storage.pm b/src/PVE/Storage.pm
index 891d1885..2fc91a82 100755
--- a/src/PVE/Storage.pm
+++ b/src/PVE/Storage.pm
@@ -1487,6 +1487,7 @@ sub storage_info {
 
     # Status-only activation must not wait for resources needed only when using a storage.
     my $cache = { status_only => 1 };
+    my $warned = {};
 
     foreach my $storeid (keys %$ids) {
         my $scfg = $ids->{$storeid};
@@ -1518,12 +1519,14 @@ sub storage_info {
 
         eval { activate_storage($cfg, $storeid, $cache); };
         if (my $err = $@) {
-            warn $err;
+            warn $err if !$warned->{$err}++;
             next;
         }
 
         my ($total, $avail, $used, $active) = eval { $plugin->status($storeid, $scfg, $cache); };
-        warn $@ if $@;
+        if (my $err = $@) {
+            warn $err if !$warned->{$err}++;
+        }
         next if !$active;
         $info->{$storeid}->{total} = int($total);
         $info->{$storeid}->{avail} = int($avail);
@@ -1531,6 +1534,12 @@ sub storage_info {
         $info->{$storeid}->{active} = $active;
     }
 
+    # A later command failure invalidates earlier results from the same shared inventory.
+    for my $storeid (keys(($cache->{'withdrawn-status'} // {})->%*)) {
+        next if !$info->{$storeid};
+        $info->{$storeid}->{$_} = 0 for qw(total avail used active);
+    }
+
     return $info;
 }
 
diff --git a/src/PVE/Storage/LVMPlugin.pm b/src/PVE/Storage/LVMPlugin.pm
index 3ed5bc78..eb9806b2 100644
--- a/src/PVE/Storage/LVMPlugin.pm
+++ b/src/PVE/Storage/LVMPlugin.pm
@@ -20,6 +20,7 @@ use PVE::Tools qw(run_command file_read_firstline trim);
 
 use PVE::Storage::Common;
 use PVE::Storage::Plugin;
+use PVE::Storage::StatusCommand;
 
 use base qw(PVE::Storage::Plugin);
 
@@ -160,8 +161,9 @@ sub lvm_destroy_volume_group {
     );
 }
 
+# Status-only callers must reject partial listings.
 sub lvm_vgs {
-    my ($includepvs, $nohints) = @_;
+    my ($includepvs, $nohints, $cache) = @_;
 
     my $cmd = [
         '/sbin/vgs',
@@ -186,7 +188,8 @@ sub lvm_vgs {
 
     my $vgs = {};
     eval {
-        run_command(
+        PVE::Storage::StatusCommand::run_for_cache(
+            $cache,
             $cmd,
             outfunc => sub {
                 my $line = shift;
@@ -214,6 +217,7 @@ sub lvm_vgs {
         );
     };
     my $err = $@;
+    die $err if $err && $cache && $cache->{status_only};
 
     # just warn (vgs return error code 5 if clvmd does not run)
     # but output is still OK (list without clustered VGs)
@@ -223,7 +227,7 @@ sub lvm_vgs {
 }
 
 sub lvm_list_volumes {
-    my ($vgname) = @_;
+    my ($vgname, $cache) = @_;
 
     my $option_list =
         'vg_name,lv_name,lv_size,lv_attr,pool_lv,data_percent,metadata_percent,snap_percent,uuid,tags,metadata_size,time';
@@ -246,7 +250,8 @@ sub lvm_list_volumes {
     push @$cmd, $vgname if $vgname;
 
     my $lvs = {};
-    run_command(
+    PVE::Storage::StatusCommand::run_for_cache(
+        $cache,
         $cmd,
         outfunc => sub {
             my $line = shift;
@@ -1353,15 +1358,17 @@ sub list_images {
 sub status {
     my ($class, $storeid, $scfg, $cache) = @_;
 
-    $cache->{vgs} = lvm_vgs() if !$cache->{vgs};
+    return PVE::Storage::StatusCommand::collect_shared_status(
+        $cache,
+        $storeid,
+        'LVM',
+        sub {
+            $cache->{vgs} = lvm_vgs(undef, undef, $cache) if !$cache->{vgs};
 
-    my $vgname = $scfg->{vgname};
-
-    if (my $info = $cache->{vgs}->{$vgname}) {
-        return ($info->{size}, $info->{free}, $info->{size} - $info->{free}, 1);
-    }
-
-    return undef;
+            my $info = $cache->{vgs}->{ $scfg->{vgname} } or return undef;
+            return ($info->{size}, $info->{free}, $info->{size} - $info->{free}, 1);
+        },
+    );
 }
 
 sub volume_snapshot_info {
@@ -1429,14 +1436,21 @@ my sub find_unhinted_vgs {
 
     $cache->{vgscaned} = 1;
 
-    my $vgs = lvm_vgs(0, 1);
+    my $vgs = lvm_vgs(0, 1, $cache);
     my @unhinted = grep { !$cache->{vgs}->{$_} } sort keys %$vgs;
     return if !@unhinted;
 
     eval {
-        run_command(['/sbin/pvscan', '--cache'], outfunc => sub { });
+        PVE::Storage::StatusCommand::run_for_cache(
+            $cache,
+            ['/sbin/pvscan', '--cache'],
+            outfunc => sub { },
+        );
     };
-    warn "rebuilding the LVM device hints failed - $@" if $@;
+    if (my $err = $@) {
+        die $err if $cache->{status_only};
+        warn "rebuilding the LVM device hints failed - $err";
+    }
 
     # add, never replace: an incomplete listing must not hide volume groups found before
     $cache->{vgs}->{$_} = $vgs->{$_} for @unhinted;
@@ -1447,9 +1461,17 @@ my sub find_unhinted_vgs {
 sub activate_storage {
     my ($class, $storeid, $scfg, $cache) = @_;
 
-    $cache->{vgs} = lvm_vgs() if !$cache->{vgs};
+    PVE::Storage::StatusCommand::collect_shared_status(
+        $cache,
+        $storeid,
+        'LVM',
+        sub {
+            $cache->{vgs} = lvm_vgs(undef, undef, $cache) if !$cache->{vgs};
 
-    find_unhinted_vgs($cache) if !$cache->{vgscaned} && !$cache->{vgs}->{ $scfg->{vgname} };
+            find_unhinted_vgs($cache)
+                if !$cache->{vgscaned} && !$cache->{vgs}->{ $scfg->{vgname} };
+        },
+    );
 
     # we do not acticate any volumes here ('vgchange -aly')
     # instead, volumes are activate individually later
diff --git a/src/PVE/Storage/LvmThinPlugin.pm b/src/PVE/Storage/LvmThinPlugin.pm
index 6b953693..01b3164d 100644
--- a/src/PVE/Storage/LvmThinPlugin.pm
+++ b/src/PVE/Storage/LvmThinPlugin.pm
@@ -10,6 +10,7 @@ use PVE::Tools qw(run_command trim);
 
 use PVE::Storage::Plugin;
 use PVE::Storage::LVMPlugin;
+use PVE::Storage::StatusCommand;
 
 # see: man lvmthin
 # lvcreate -n ThinDataLV -L LargeSize VG
@@ -229,32 +230,41 @@ sub list_thinpools {
 sub status {
     my ($class, $storeid, $scfg, $cache) = @_;
 
-    my $lvs = $cache->{lvs} ||= PVE::Storage::LVMPlugin::lvm_list_volumes();
+    return PVE::Storage::StatusCommand::collect_shared_status(
+        $cache,
+        $storeid,
+        'LVM',
+        sub {
+            my $lvs = $cache->{lvs} ||=
+                PVE::Storage::LVMPlugin::lvm_list_volumes(undef, $cache);
 
-    return if !$lvs->{ $scfg->{vgname} };
+            return if !$lvs->{ $scfg->{vgname} };
 
-    my $info = $lvs->{ $scfg->{vgname} }->{ $scfg->{thinpool} };
+            my $info = $lvs->{ $scfg->{vgname} }->{ $scfg->{thinpool} };
 
-    return if !$info || $info->{lv_type} ne 't' || !$info->{lv_size};
+            return if !$info || $info->{lv_type} ne 't' || !$info->{lv_size};
 
-    return (
-        $info->{lv_size},
-        $info->{lv_size} - $info->{used},
-        $info->{used},
-        $info->{lv_state} eq 'a' ? 1 : 0,
+            return (
+                $info->{lv_size},
+                $info->{lv_size} - $info->{used},
+                $info->{used},
+                $info->{lv_state} eq 'a' ? 1 : 0,
+            );
+        },
     );
 }
 
 my $activate_lv = sub {
     my ($vg, $lv, $cache) = @_;
 
-    my $lvs = $cache->{lvs} ||= PVE::Storage::LVMPlugin::lvm_list_volumes();
+    my $lvs = $cache->{lvs} ||= PVE::Storage::LVMPlugin::lvm_list_volumes(undef, $cache);
 
     die "no such logical volume $vg/$lv\n" if !$lvs->{$vg} || !$lvs->{$vg}->{$lv};
 
     return if $lvs->{$vg}->{$lv}->{lv_state} eq 'a';
 
-    run_command(
+    PVE::Storage::StatusCommand::run_for_cache(
+        $cache,
         ['lvchange', '-ay', '-K', "$vg/$lv"],
         errmsg => "activating LV '$vg/$lv' failed",
     );
@@ -269,7 +279,12 @@ sub activate_storage {
 
     $class->SUPER::activate_storage($storeid, $scfg, $cache);
 
-    $activate_lv->($scfg->{vgname}, $scfg->{thinpool}, $cache);
+    PVE::Storage::StatusCommand::collect_shared_status(
+        $cache,
+        $storeid,
+        'LVM',
+        sub { $activate_lv->($scfg->{vgname}, $scfg->{thinpool}, $cache) },
+    );
 }
 
 sub activate_volume {
diff --git a/src/PVE/Storage/Makefile b/src/PVE/Storage/Makefile
index a67dc25f..47813553 100644
--- a/src/PVE/Storage/Makefile
+++ b/src/PVE/Storage/Makefile
@@ -14,7 +14,8 @@ SOURCES= \
 	PBSPlugin.pm \
 	BTRFSPlugin.pm \
 	LvmThinPlugin.pm \
-	ESXiPlugin.pm
+	ESXiPlugin.pm \
+	StatusCommand.pm
 
 .PHONY: install
 install:
diff --git a/src/PVE/Storage/StatusCommand.pm b/src/PVE/Storage/StatusCommand.pm
new file mode 100644
index 00000000..b0b48d59
--- /dev/null
+++ b/src/PVE/Storage/StatusCommand.pm
@@ -0,0 +1,241 @@
+package PVE::Storage::StatusCommand;
+
+# LVM can remain blocked in I/O after SIGKILL. Child stdin retains a node-wide lock so another
+# observer does not start a competing command while it stays held. Every observer waits briefly for
+# the lock, including when a previous command did not stop. The shared command budget allows 0.1
+# seconds for cleanup, excluding scheduling overhead; it does not bound the entire status pass.
+
+use v5.36;
+
+use Fcntl qw(:DEFAULT :flock);
+use File::Basename qw(basename);
+use IO::Select;
+use POSIX qw(:sys_wait_h);
+use Time::HiRes qw(clock_gettime CLOCK_MONOTONIC);
+
+use PVE::Tools;
+
+our $LOCK_FILE = '/run/pve-storage-status-command.lock';
+our $KILL_SIGNAL = 'KILL';
+
+# the time all status commands of one pass, sharing an activation cache, may take together
+our $BUDGET = 15;
+
+our $OUTPUT_LIMIT = 16 * 1024 * 1024;
+
+our $CLOCK = sub () { return clock_gettime(CLOCK_MONOTONIC); };
+
+# A forked child must not take over its parent's pending commands: { pid => name }.
+my $pending = {};
+my $pending_owner = $$;
+
+sub reap_pending() {
+    if ($pending_owner != $$) {
+        ($pending, $pending_owner) = ({}, $$);
+        return;
+    }
+    for my $pid (keys %$pending) {
+        my $res = waitpid($pid, WNOHANG);
+        next if $res == 0 || ($res < 0 && $!{EINTR});
+        delete $pending->{$pid};
+    }
+}
+
+# Re-exec preserves the PID and child relationships; pending children must survive the handover.
+sub export_pending() {
+    reap_pending();
+    return "$$:" . join(',', map { "$_=$pending->{$_}" } sort keys %$pending);
+}
+
+# Restore the pending commands that export_pending() of this same process saved before its re-exec.
+sub import_pending($value) {
+    my ($owner, $list) = ($value // '') =~ m/^(\d+):((?:\d+=[\w.-]+,?)*)$/ or return;
+    return if $owner != $$;
+    ($pending, $pending_owner) = ({}, $$);
+    for my $entry (split(/,/, $list)) {
+        my ($pid, $name) = $entry =~ m/^(\d+)=([\w.-]+)$/;
+        $pending->{$pid} = $name;
+    }
+    reap_pending();
+}
+
+my sub exec_child($cmd, $lock, $out, $err) {
+    eval {
+        $SIG{$_} = 'DEFAULT' for keys %SIG;
+        POSIX::sigprocmask(POSIX::SIG_SETMASK(), POSIX::SigSet->new());
+        POSIX::dup2(fileno($lock), 0) // die;
+        POSIX::dup2(fileno($out), 1) // die;
+        POSIX::dup2(fileno($err), 2) // die;
+        for my $fd (map { m!/(\d+)$! ? $1 : () } glob('/proc/self/fd/*')) {
+            POSIX::close($fd) if $fd > 2;
+        }
+        # the output is parsed, and LVM must not write its reports to other descriptors
+        $ENV{LC_ALL} = 'C';
+        delete $ENV{$_} for grep { m/^LVM_\w+_FD$/ } keys %ENV;
+        exec({ $cmd->[0] } $cmd->@*);
+    };
+    POSIX::_exit(127);
+}
+
+my sub collect($pid, $name, $deadline, $out, $err, %param) {
+    my $func = { fileno($out) => $param{outfunc}, fileno($err) => $param{errfunc} };
+    my $buffer = { fileno($out) => '', fileno($err) => '' };
+    my $select = IO::Select->new($out, $err);
+    my ($length, $last_error) = (0, '');
+
+    my $line = sub($fd, $text) {
+        $last_error = $text if $fd == fileno($err) && $text =~ m/\S/;
+        $func->{$fd}->($text) if $func->{$fd};
+    };
+
+    while (1) {
+        if (!$select->count()) {
+            my $res = waitpid($pid, WNOHANG);
+            return ($?, $last_error) if $res == $pid;
+            die "status command '$name' was reaped elsewhere\n" if $res < 0;
+        }
+        my $left = $deadline - $CLOCK->();
+        die "status command '$name' did not finish within the shared $BUDGET-second limit\n"
+            if $left <= 0;
+        if (!$select->count()) {
+            # the output is complete, but the process did not exit yet
+            select(undef, undef, undef, $left < 0.01 ? $left : 0.01);
+            next;
+        }
+        for my $fh ($select->can_read($left)) {
+            my $fd = fileno($fh);
+            my $count = sysread($fh, my $data, 65536);
+            if (!$count) {
+                $select->remove($fh);
+                $line->($fd, $buffer->{$fd}) if length($buffer->{$fd});
+                next;
+            }
+            $length += $count;
+            die "status command '$name' wrote more than $OUTPUT_LIMIT bytes\n"
+                if $length > $OUTPUT_LIMIT;
+            $buffer->{$fd} .= $data;
+            $line->($fd, $1) while $buffer->{$fd} =~ s/^([^\n]*)\n//;
+        }
+    }
+}
+
+my sub run_bounded($cmd, $deadline, %param) {
+    my $name = basename($cmd->[0]);
+    reap_pending();
+    die "status command '$name' not started - shared time limit of $BUDGET seconds reached\n"
+        if $CLOCK->() >= $deadline;
+
+    sysopen(my $lock, $LOCK_FILE, O_RDWR | O_CREAT, 0600)
+        or die "opening '$LOCK_FILE' failed - $!\n";
+    if (!flock($lock, LOCK_EX | LOCK_NB)) {
+        die "locking '$LOCK_FILE' failed - $!\n" if !$!{EWOULDBLOCK} && !$!{EAGAIN} && !$!{EINTR};
+
+        my $wait_until = $CLOCK->() + 2;
+        my $budget_limited = $deadline - 0.5 < $wait_until;
+        $wait_until = $deadline - 0.5 if $budget_limited;
+        my $locked = eval {
+            # Use scalar context: list context reports timeout separately instead of throwing it.
+            my $left = $wait_until - $CLOCK->();
+            die "got timeout\n" if $left <= 0;
+            scalar PVE::Tools::run_with_timeout(
+                $left,
+                sub () {
+                    while (!flock($lock, LOCK_EX)) {
+                        die "locking '$LOCK_FILE' failed - $!\n" if !$!{EINTR};
+                        die "got timeout\n" if $CLOCK->() >= $wait_until;
+                    }
+                    return 1;
+                },
+            );
+        };
+        if (!$locked) {
+            my $err = $@;
+            die $err if $err ne "got timeout\n";
+            die "status command '$name' not started - not enough time left after waiting for"
+                . " another status command (shared limit: $BUDGET seconds)\n"
+                if $budget_limited;
+            die "another status command is still running\n";
+        }
+    }
+
+    pipe(my $out_r, my $out_w) or die "pipe failed - $!\n";
+    pipe(my $err_r, my $err_w) or die "pipe failed - $!\n";
+    die
+        "status command '$name' not started - not enough time left (shared limit: $BUDGET seconds)\n"
+        if $deadline - $CLOCK->() < 0.5;
+    my $pid = fork() // die "fork failed - $!\n";
+    exec_child($cmd, $lock, $out_w, $err_w) if $pid == 0;
+
+    # the child holds the lock from now on, until it exits; no unlock, that would end it for both
+    close($lock);
+    close($out_w);
+    close($err_w);
+
+    my ($status, $last_error) = eval { collect($pid, $name, $deadline, $out_r, $err_r, %param) };
+    if (my $err = $@) {
+        # Use one cleanup deadline across interrupted polls; debit it from the shared budget and
+        # never block in waitpid(), even after killing the child.
+        my $cleanup_until = $CLOCK->() + 0.1;
+        kill($KILL_SIGNAL, $pid);
+        while (1) {
+            my $res = waitpid($pid, WNOHANG);
+            last if $res != 0 && !($res < 0 && $!{EINTR});
+            my $left = $cleanup_until - $CLOCK->();
+            if ($left <= 0) {
+                $pending->{$pid} = $name;
+                last;
+            }
+            select(undef, undef, undef, $left < 0.01 ? $left : 0.01);
+        }
+        die $err;
+    }
+
+    my $detail = $last_error ne '' ? " - $last_error" : '';
+    die "status command '$name' failed: exit code " . ($status >> 8) . "$detail\n" if $status >> 8;
+    die "status command '$name' failed: killed by signal " . ($status & 127) . "\n"
+        if $status & 127;
+    return 0;
+}
+
+# Command, timeout or contention failures poison subsequent commands sharing this cache.
+sub run($cache, $cmd, %param) {
+    die $cache->{'status-command-error'} if defined($cache->{'status-command-error'});
+
+    my $start = $CLOCK->();
+    my $budget = $cache->{'status-command-budget'} // $BUDGET;
+    my $res = eval { run_bounded($cmd, $start + $budget, %param) };
+    my $err = $@;
+    $cache->{'status-command-budget'} = $budget - ($CLOCK->() - $start);
+    if ($err) {
+        $cache->{'status-command-error'} = $err;
+        die $err;
+    }
+    return $res;
+}
+
+sub run_for_cache($cache, $cmd, %param) {
+    return run($cache, $cmd, %param) if $cache && $cache->{status_only};
+    return PVE::Tools::run_command($cmd, %param);
+}
+
+# A command failure invalidates all results sharing this inventory, including earlier successes;
+# storage_info() withdraws them. Name the shared kind in errors so the status loop can deduplicate.
+sub collect_shared_status($cache, $storeid, $kind, $code) {
+    return $code->() if !$cache || !$cache->{status_only};
+
+    $cache->{'shared-status'}->{$kind}->{$storeid} = 1;
+    my $want = wantarray;
+    my @res = eval {
+        die $cache->{'status-command-error'} if defined($cache->{'status-command-error'});
+        $want ? $code->() : scalar($code->());
+    };
+    my $err = $@;
+    if (defined(my $failed = $cache->{'status-command-error'})) {
+        $cache->{'withdrawn-status'}->{$_} = 1 for keys $cache->{'shared-status'}->{$kind}->%*;
+        die "$kind status unavailable - $failed";
+    }
+    die $err if $err;
+    return $want ? @res : $res[0];
+}
+
+1;
diff --git a/src/test/Makefile b/src/test/Makefile
index 02d833e8..0b9b95b2 100644
--- a/src/test/Makefile
+++ b/src/test/Makefile
@@ -1,6 +1,6 @@
 all: test
 
-test: test_iscsi test_zfspoolplugin test_lvmplugin test_lvm_lock_retry test_lvm_base_volume test_lvm_hints test_disklist test_bwlimit test_plugin test_ovf test_volume_access
+test: test_iscsi test_zfspoolplugin test_lvmplugin test_lvm_lock_retry test_lvm_base_volume test_lvm_hints test_lvm_status test_status_command test_disklist test_bwlimit test_plugin test_ovf test_volume_access test_san_libraries
 
 test_zfspoolplugin: run_test_zfspoolplugin.pl
 	./run_test_zfspoolplugin.pl
@@ -18,6 +18,13 @@ test_lvm_base_volume: run_test_lvm_base_volume.pl
 test_lvm_hints: run_test_lvm_hints.pl
 	./run_test_lvm_hints.pl
 
+test_lvm_status: run_test_lvm_status.pl
+	./run_test_lvm_status.pl
+
+test_status_command: run_status_command_tests.pl run_status_command_contention_tests.pl
+	./run_status_command_tests.pl
+	./run_status_command_contention_tests.pl
+
 test_disklist: run_disk_tests.pl
 	./run_disk_tests.pl
 
@@ -37,3 +44,6 @@ test_iscsi: run_iscsi_discovery_tests.pl run_iscsi_session_tests.pl run_iscsi_vo
 	./run_iscsi_discovery_tests.pl
 	./run_iscsi_session_tests.pl
 	./run_iscsi_volume_tests.pl
+
+test_san_libraries: run_san_library_tests.pl
+	./run_san_library_tests.pl
diff --git a/src/test/run_san_library_tests.pl b/src/test/run_san_library_tests.pl
new file mode 100755
index 00000000..6d509979
--- /dev/null
+++ b/src/test/run_san_library_tests.pl
@@ -0,0 +1,35 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+
+use File::Temp qw(tempdir);
+use Test::MockModule;
+use Test::More;
+
+use lib ('.', '..');
+use PVE::Cluster;
+use PVE::Storage::StatusCommand;
+
+subtest 'shared status retains callback context and named command parameters' => sub {
+    for my $cache (undef, { status_only => 1 }) {
+        my $code = sub { return wantarray ? ('a', 'b') : 'scalar' };
+        my @result =
+            PVE::Storage::StatusCommand::collect_shared_status($cache, 'san', 'lvm', $code);
+        is_deeply(\@result, ['a', 'b'], 'list context reaches the callback');
+        my $result =
+            PVE::Storage::StatusCommand::collect_shared_status($cache, 'san', 'lvm', $code);
+        is($result, 'scalar', 'scalar context reaches the callback');
+    }
+    my $tools = Test::MockModule->new('PVE::Tools');
+    my @args;
+    $tools->mock(run_command => sub { @args = @_; return 7 });
+    is(
+        PVE::Storage::StatusCommand::run_for_cache(undef, ['probe'], timeout => 3),
+        7,
+        'command result is retained',
+    );
+    is_deeply(\@args, [['probe'], timeout => 3], 'named parameters are forwarded');
+};
+
+done_testing();
diff --git a/src/test/run_status_command_contention_tests.pl b/src/test/run_status_command_contention_tests.pl
new file mode 100755
index 00000000..3c535d33
--- /dev/null
+++ b/src/test/run_status_command_contention_tests.pl
@@ -0,0 +1,520 @@
+#!/usr/bin/perl
+
+# Real-clock, cross-process admission tests. Each lock holder announces readiness before another
+# observer starts, and every stand-in has a release channel and a safety alarm. Exec modes exercise
+# the daemon's opaque handover while its pending direct child is alive and after that child exits.
+
+use strict;
+use warnings;
+
+use lib '..';
+use Fcntl qw(:DEFAULT :flock);
+use File::Temp qw(tempdir);
+use JSON::PP qw(encode_json decode_json);
+use POSIX ();
+use Test::More;
+use Time::HiRes qw(clock_gettime CLOCK_MONOTONIC sleep);
+
+use PVE::Tools;
+use PVE::Storage::StatusCommand;
+
+my $mode = shift(@ARGV) // '';
+my $dir = $mode ? shift(@ARGV) : tempdir(CLEANUP => 1);
+$PVE::Storage::StatusCommand::LOCK_FILE = "$dir/lock";
+my $timer = \&PVE::Tools::run_with_timeout;
+my $busy = "another status command is still running\n";
+my (@children, @commands);
+my $sequence = 0;
+
+my sub now { return clock_gettime(CLOCK_MONOTONIC); }
+
+my sub put {
+    my ($path, $value) = @_;
+    open(my $fh, '>', $path) or die "open $path: $!\n";
+    print {$fh} $value or die "write $path: $!\n";
+    close($fh) or die "close $path: $!\n";
+}
+
+my sub get {
+    my ($path) = @_;
+    open(my $fh, '<', $path) or die "open $path: $!\n";
+    local $/;
+    return <$fh>;
+}
+
+my sub await {
+    my ($code, $what) = @_;
+    my $deadline = now() + 5;
+    until ($code->()) {
+        die "timed out waiting for $what\n" if now() >= $deadline;
+        sleep(0.005);
+    }
+}
+
+my sub release {
+    my ($fifo) = @_;
+    await(
+        sub {
+            if (sysopen(my $fh, $fifo, O_WRONLY | O_NONBLOCK)) {
+                close($fh);
+                return 1;
+            }
+            die "open $fifo: $!\n" if !$!{ENXIO};
+            return 0;
+        },
+        "FIFO reader $fifo",
+    );
+}
+
+my sub spawn {
+    my ($code) = @_;
+    my $path = "$dir/job-" . ++$sequence;
+    my $pid = fork() // die "fork: $!\n";
+    if (!$pid) {
+        @children = @commands = ();
+        # Signal trains can overlap result publication after a case restores its local handler.
+        $SIG{USR1} = 'IGNORE';
+        alarm(12);
+        my $result = eval { $code->($path) };
+        $result = { fatal => $@ } if $@;
+        put($path, encode_json($result));
+        POSIX::_exit(0);
+    }
+    push @children, $pid;
+    return { pid => $pid, path => $path };
+}
+
+my sub finish {
+    my ($job) = @_;
+    waitpid($job->{pid}, 0) == $job->{pid} or die "waitpid: $!\n";
+    die "test child failed: $?\n" if $?;
+    @children = grep { $_ != $job->{pid} } @children;
+    my $result = decode_json(get($job->{path}));
+    die $result->{fatal} if $result->{fatal};
+    return $result;
+}
+
+my sub observe {
+    my ($budget, $cmd, %param) = @_;
+    my $cache = { 'status-command-budget' => $budget };
+    # Compare debiting against the same clock samples, not scheduler time outside run().
+    my ($first, $last);
+    my $clock = $PVE::Storage::StatusCommand::CLOCK;
+    local $PVE::Storage::StatusCommand::CLOCK = sub {
+        $last = $clock->();
+        $first //= $last;
+        return $last;
+    };
+    my $start = now();
+    eval { PVE::Storage::StatusCommand::run($cache, $cmd, %param) };
+    my $err = $@;
+    my $elapsed = now() - $start;
+    eval { PVE::Storage::StatusCommand::run($cache, ['/bin/true']) } if $err;
+    return {
+        error => $err,
+        elapsed => $elapsed,
+        charged => $last - $first,
+        remaining => $cache->{'status-command-budget'},
+        cached => $err ? $@ : '',
+    };
+}
+
+my sub contender {
+    my ($budget, $tag, $delay_after_lock) = @_;
+    return spawn(
+        sub {
+            no warnings 'redefine';
+            local *PVE::Tools::run_with_timeout = sub {
+                put("$dir/$tag-waiting", '');
+                my $res = scalar $timer->(@_);
+                sleep($delay_after_lock) if $delay_after_lock;
+                return $res;
+            };
+            return observe($budget, ['/bin/sh', '-c', "echo run >> '$dir/launched'"]);
+        },
+    );
+}
+
+my sub holder {
+    my $fifo = "$dir/holder-" . ++$sequence;
+    POSIX::mkfifo($fifo, 0600) or die "mkfifo: $!\n";
+    my $job = spawn(
+        sub {
+            sysopen(my $lock, $PVE::Storage::StatusCommand::LOCK_FILE, O_RDWR | O_CREAT, 0600)
+                or die "open lock: $!\n";
+            flock($lock, LOCK_EX) or die "flock: $!\n";
+            put("$fifo-ready", '');
+            open(my $fh, '<', $fifo) or die "open FIFO: $!\n";
+            close($fh);
+            return {};
+        },
+    );
+    await(sub { -e "$fifo-ready" }, 'holder readiness');
+    return ($job, $fifo);
+}
+
+my $standin = <<'PERL';
+use strict;
+use warnings;
+$SIG{USR1} = 'IGNORE';
+alarm(10);
+open(my $pidfile, '>', $ARGV[1]) or die $!;
+print {$pidfile} $$;
+close($pidfile);
+$| = 1;
+print "ready\n";
+print 'x' x 4096 if $ARGV[2];
+open(my $fh, '<', $ARGV[0]) or die $!;
+close($fh);
+PERL
+
+my sub abandon {
+    my ($kind, $callback) = @_;
+    my $fifo = "$dir/command-" . ++$sequence;
+    POSIX::mkfifo($fifo, 0600) or die "mkfifo: $!\n";
+    local $PVE::Storage::StatusCommand::KILL_SIGNAL = 'USR1';
+    local $PVE::Storage::StatusCommand::OUTPUT_LIMIT = $kind eq 'output' ? 1024 : 16384;
+    # Start the short timeout only once output proves that the helper installed its signal
+    # handler and wrote its PID. Startup scheduling must not turn it into a killed child.
+    my $advance = 0;
+    my $started = now();
+    local $PVE::Storage::StatusCommand::CLOCK = sub { now() + $advance };
+    my $result = observe(
+        10,
+        [$^X, '-e', $standin, $fifo, "$fifo-pid", $kind eq 'output'],
+        outfunc => sub {
+            $advance = 9.45 - (now() - $started) if $kind eq 'timeout';
+            $callback->() if $callback;
+            die "callback refused output\n" if $kind eq 'callback';
+        },
+    );
+    await(sub { -s "$fifo-pid" }, 'command PID');
+    my $pid = get("$fifo-pid");
+    push @commands, $pid;
+    return ($result, $pid, $fifo);
+}
+
+my sub reap {
+    my ($pid) = @_;
+    await(
+        sub {
+            PVE::Storage::StatusCommand::reap_pending();
+            return !-e "/proc/$pid/stat";
+        },
+        'abandoned child reap',
+    );
+    @commands = grep { $_ != $pid } @commands;
+}
+
+# Both handovers export while the child is alive. The second waits for a zombie before exec so
+# import must reap it, rather than merely restore a live child's pending entry.
+if ($mode eq '--handover-start') {
+    my (undef, $pid, $fifo) = abandon('callback');
+    my $state = {
+        pending => PVE::Storage::StatusCommand::export_pending(),
+        pid => $pid,
+        fifo => $fifo,
+    };
+    put("$dir/handover-state", encode_json($state));
+    put("$dir/handover-ready", '');
+    await(sub { -e "$dir/exec-live" }, 'live exec request');
+    exec($^X, $0, '--handover-live', $dir) or die "exec: $!\n";
+} elsif ($mode eq '--handover-live') {
+    my $state = decode_json(get("$dir/handover-state"));
+    PVE::Storage::StatusCommand::import_pending($state->{pending});
+    die 'pending child lost during exec'
+        if PVE::Storage::StatusCommand::export_pending() ne $state->{pending};
+    put("$dir/handover-live", '');
+    await(sub { -e "$dir/exec-exited" }, 'exited exec request');
+    $state->{pending} = PVE::Storage::StatusCommand::export_pending();
+    release($state->{fifo});
+    await(
+        sub { get("/proc/$state->{pid}/stat") =~ m/^\d+ \(.*\) Z /s },
+        'child exit during handover',
+    );
+    put("$dir/handover-state", encode_json($state));
+    exec($^X, $0, '--handover-exited', $dir) or die "exec: $!\n";
+} elsif ($mode eq '--handover-exited') {
+    my $state = decode_json(get("$dir/handover-state"));
+    PVE::Storage::StatusCommand::import_pending($state->{pending});
+    die 'exited child not reaped' if -e "/proc/$state->{pid}/stat";
+    die 'pending not empty' if PVE::Storage::StatusCommand::export_pending() ne "$$:";
+    put("$dir/handover-done", '');
+    POSIX::_exit(0);
+}
+
+END {
+    kill('KILL', $_) for @children, @commands;
+    waitpid($_, 0) for @children, @commands;
+}
+
+alarm(45);
+
+# Independent status and scan observers share admission, not their 15/10 second budgets or cache.
+{
+    my $fifo = "$dir/healthy";
+    POSIX::mkfifo($fifo, 0600) or die "mkfifo: $!\n";
+    my $first = spawn(
+        sub {
+            return observe(
+                15,
+                [
+                    $^X,
+                    '-e',
+                    '$| = 1; print "ready\n"; open(my $f, "<", $ARGV[0]) or die',
+                    $fifo,
+                ],
+                outfunc => sub { put("$dir/healthy-ready", '') },
+            );
+        },
+    );
+    await(sub { -e "$dir/healthy-ready" }, 'first observer');
+    my $second = contender(10, 'healthy');
+    await(sub { -e "$dir/healthy-waiting" }, 'second observer');
+    sleep(0.12);
+    release($fifo);
+    my $a = finish($first);
+    my $b = finish($second);
+    is($a->{error}, '', 'healthy: status observer succeeds');
+    is($b->{error}, '', 'healthy: simultaneous scan observer waits and succeeds');
+    cmp_ok($b->{elapsed}, '>=', 0.1, 'healthy: real lock wait occurred');
+    cmp_ok($b->{elapsed}, '<', 1.5, 'healthy: released within wait cap');
+    cmp_ok(abs(10 - $b->{remaining} - $b->{charged}), '<', 0.00001, 'healthy: wait debited');
+    cmp_ok($a->{remaining}, '>', $b->{remaining} + 4, 'healthy: budgets remain independent');
+    is(get("$dir/launched"), "run\n", 'healthy: second observer launched exactly one command');
+    unlink("$dir/launched") or die $!;
+}
+
+{
+    my ($h, $fifo) = holder();
+    my $result = observe(10, ['/bin/sh', '-c', "touch '$dir/launched'"]);
+    is($result->{error}, $busy, 'holder: two-second cap returns busy');
+    cmp_ok($result->{elapsed}, '>=', 1.9, 'holder: did not fail at once');
+    cmp_ok($result->{elapsed}, '<', 4, 'holder: bounded wait');
+    cmp_ok(abs(10 - $result->{remaining} - $result->{charged}), '<', 0.00001, 'cap: wait debited');
+    is($result->{cached}, $busy, 'cap: first failure cached');
+    ok(!-e "$dir/launched", 'cap: timed out without forking a command');
+    release($fifo);
+    finish($h);
+}
+
+{
+    my ($h, $fifo) = holder();
+    my $waiter = spawn(
+        sub {
+            my $interrupts = 0;
+            local $SIG{USR1} = sub { $interrupts++ };
+            no warnings 'redefine';
+            local *PVE::Tools::run_with_timeout = sub {
+                put("$dir/eintr-ready", '');
+                return scalar $timer->(@_);
+            };
+            my $result = observe(1.25, ['/bin/sh', '-c', "touch '$dir/launched'"]);
+            $result->{interrupts} = $interrupts;
+            return $result;
+        },
+    );
+    await(sub { -e "$dir/eintr-ready" }, 'interruptible wait');
+    # A renewed 0.75-second deadline would follow the signal train for four seconds. Stop
+    # sending as soon as the observer finishes, so the correct path does not pay that cost.
+    my $until = now() + 4;
+    while (now() < $until && !-e $waiter->{path}) {
+        kill('USR1', $waiter->{pid});
+        sleep(0.01);
+    }
+    my $result = finish($waiter);
+    like(
+        $result->{error},
+        qr/not started - not enough time left after waiting for another status command \(shared limit: 15 seconds\)/,
+        'short wait: own reserve is limiting',
+    );
+    cmp_ok($result->{interrupts}, '>=', 2, 'EINTR: repeated signals reached blocked observer');
+    cmp_ok($result->{elapsed}, '>=', 0.7, 'EINTR: waited for fixed deadline');
+    cmp_ok($result->{elapsed}, '<', 1.75, 'EINTR: did not renew deadline');
+    ok(!-e "$dir/launched", 'sub-second timeout: no command launched');
+    release($fifo);
+    finish($h);
+}
+
+for my $contended (0, 1) {
+    my ($h, $fifo) = $contended ? holder() : ();
+    my $timers = 0;
+    no warnings 'redefine';
+    local *PVE::Tools::run_with_timeout = sub {
+        $timers++;
+        return scalar $timer->(@_);
+    };
+    my $result = observe(0.49, ['/bin/sh', '-c', "touch '$dir/launched'"]);
+    like(
+        $result->{error},
+        $contended
+        ? qr/not started - not enough time left after waiting for another status command \(shared limit: 15 seconds\)/
+        : qr/not started - not enough time left \(shared limit: 15 seconds\)/,
+        "floor: rejects admission, contended=$contended",
+    );
+    is($timers, 0, 'floor: no nonpositive timer installed');
+    cmp_ok($result->{elapsed}, '<', 1, 'floor: rejects without waiting');
+    ok(!-e "$dir/launched", 'floor: no command launched');
+    if ($h) {
+        release($fifo);
+        finish($h);
+    }
+}
+
+{
+    my ($h, $fifo) = holder();
+    my $waiter = contender(1.5, 'late', 1.1);
+    await(sub { -e "$dir/late-waiting" }, 'late waiter');
+    release($fifo);
+    finish($h);
+    my $result = finish($waiter);
+    like(
+        $result->{error},
+        qr/not started - not enough time left \(shared limit: 15 seconds\)/,
+        'late lock: pre-fork floor rechecked',
+    );
+    ok(!-e "$dir/launched", 'late lock: no command launched after admission delay');
+}
+
+{
+    my ($result, $pid, $fifo) = abandon('timeout');
+    like(
+        $result->{error},
+        qr/did not finish within the shared 15-second limit/,
+        'left behind: direct child abandoned',
+    );
+    cmp_ok($result->{elapsed}, '>=', 0.64, 'cleanup: polls for the single grace');
+    cmp_ok(
+        $result->{elapsed},
+        '<',
+        1.8,
+        'cleanup: single grace plus scheduling, not the helper safety alarm',
+    );
+    cmp_ok(
+        abs(10 - $result->{remaining} - $result->{charged}),
+        '<',
+        0.00001,
+        'cleanup: grace debited',
+    );
+    is(
+        PVE::Storage::StatusCommand::export_pending(),
+        "$$:$pid=perl",
+        'left behind: direct child pending for reaping',
+    );
+    my $reader = spawn(
+        sub {
+            my @results = map {
+                observe($_, ['/bin/sh', '-c', "touch '$dir/launched'"])
+            } (15, 10);
+            return {
+                results => \@results,
+                pending => PVE::Storage::StatusCommand::export_pending(),
+                owner => $$,
+            };
+        },
+    );
+    my $reads = finish($reader);
+    for my $read ($reads->{results}->@*) {
+        is($read->{error}, $busy, 'left behind: later process fails busy');
+        cmp_ok($read->{elapsed}, '>=', 1.9, 'left behind: each observation waits');
+        cmp_ok($read->{elapsed}, '<', 4, 'left behind: each wait is bounded');
+    }
+    is($reads->{pending}, "$reads->{owner}:", 'fork: child does not inherit pending ownership');
+    is(
+        PVE::Storage::StatusCommand::export_pending(),
+        "$$:$pid=perl",
+        'fork: parent retains its pending child',
+    );
+    ok(!-e "$dir/launched", 'left behind: repeated calls launched zero commands');
+    release($fifo);
+    reap($pid);
+    is(PVE::Storage::StatusCommand::export_pending(), "$$:", 'reap: pending child removed');
+    is(observe(10, ['/bin/true'])->{error}, '', 'reap: next observation succeeds');
+}
+
+for my $kind ('callback', 'output') {
+    my ($result, $pid, $fifo) = abandon($kind);
+    like(
+        $result->{error},
+        $kind eq 'callback' ? qr/callback refused/ : qr/wrote more than/,
+        "early $kind: original failure retained",
+    );
+    is(
+        PVE::Storage::StatusCommand::export_pending(),
+        "$$:$pid=perl",
+        "early $kind: surviving direct child pending",
+    );
+    my $reader = contender(10, "early-$kind");
+    my $read = finish($reader);
+    is($read->{error}, $busy, "early $kind: later process fails busy");
+    cmp_ok($read->{elapsed}, '>=', 1.9, "early $kind: later observer waits");
+    cmp_ok($read->{elapsed}, '<', 4, "early $kind: bounded before original deadline");
+    ok(!-e "$dir/launched", "early $kind: no command launched");
+    release($fifo);
+    reap($pid);
+}
+
+{
+    my $signaler;
+    my $interrupts = 0;
+    local $SIG{USR1} = sub { $interrupts++ };
+    my ($result, $pid, $fifo) = abandon(
+        'callback',
+        sub {
+            my $owner = $$;
+            $signaler = spawn(
+                sub {
+                    my $until = now() + 4;
+                    put("$dir/cleanup-signaler-ready", '');
+                    while (now() < $until && !-e "$dir/cleanup-done") {
+                        kill('USR1', $owner);
+                        sleep(0.005);
+                    }
+                    return {};
+                },
+            );
+            await(sub { -e "$dir/cleanup-signaler-ready" }, 'cleanup signaler');
+        },
+    );
+    put("$dir/cleanup-done", '');
+    cmp_ok($interrupts, '>=', 2, 'cleanup EINTR: repeated signals delivered');
+    cmp_ok($result->{elapsed}, '>=', 0.09, 'cleanup EINTR: grace observed');
+    cmp_ok($result->{elapsed}, '<', 1.5, 'cleanup EINTR: interrupted sleeps do not renew grace');
+    cmp_ok(
+        abs(10 - $result->{remaining} - $result->{charged}),
+        '<',
+        0.00001,
+        'cleanup EINTR: entire grace debited',
+    );
+    finish($signaler);
+    release($fifo);
+    reap($pid);
+}
+
+{
+    my $pid = fork() // die "fork: $!\n";
+    if (!$pid) {
+        exec($^X, $0, '--handover-start', $dir) or die "exec: $!\n";
+    }
+    push @children, $pid;
+    await(sub { -e "$dir/handover-ready" }, 'handover owner readiness');
+    my $before = finish(contender(10, 'before-exec'));
+    is($before->{error}, $busy, 'exec: surviving child holds lock before exec');
+    cmp_ok($before->{elapsed}, '>=', 1.9, 'exec: observer waits before exec');
+    cmp_ok($before->{elapsed}, '<', 4, 'exec: wait before exec is bounded');
+    put("$dir/exec-live", '');
+    await(sub { -e "$dir/handover-live" }, 'live import after real exec');
+    my $after = finish(contender(10, 'after-exec'));
+    is($after->{error}, $busy, 'exec: surviving child holds lock after exec');
+    cmp_ok($after->{elapsed}, '>=', 1.9, 'exec: observer waits after exec');
+    cmp_ok($after->{elapsed}, '<', 4, 'exec: wait after exec is bounded');
+    put("$dir/exec-exited", '');
+    waitpid($pid, 0) == $pid or die "waitpid: $!\n";
+    is($?, 0, 'exec: owner verifies reaping after exit during second handover');
+    @children = grep { $_ != $pid } @children;
+    ok(-e "$dir/handover-done", 'exec: real exec/import/reap completed');
+    ok(!-e "$dir/launched", 'exec: later readers launched zero commands');
+}
+
+alarm(0);
+done_testing();
diff --git a/src/test/run_status_command_tests.pl b/src/test/run_status_command_tests.pl
new file mode 100755
index 00000000..65b4497a
--- /dev/null
+++ b/src/test/run_status_command_tests.pl
@@ -0,0 +1,189 @@
+#!/usr/bin/perl
+
+# Test the bounded status commands: the shared wait budget of a pass, failure handling, the lock
+# that a command left running keeps through its standard input, and the handover of pending command
+# records across a re-exec. The tests run real commands with an isolated lock path. The budget clock
+# can advance on output; separate contention tests exercise real-clock admission and abandonment.
+
+use strict;
+use warnings;
+
+use lib '..';
+use Fcntl qw(:DEFAULT :flock);
+use File::Temp qw(tempdir);
+use POSIX ();
+use Test::More;
+use Time::HiRes qw(clock_gettime CLOCK_MONOTONIC);
+
+use PVE::Tools;
+use PVE::Storage::StatusCommand;
+
+my $dir = tempdir(CLEANUP => 1);
+$PVE::Storage::StatusCommand::LOCK_FILE = "$dir/lock";
+$PVE::Storage::StatusCommand::BUDGET = 1;
+
+# the real clock, plus what the test adds
+my $skipped = 0;
+$PVE::Storage::StatusCommand::CLOCK = sub { clock_gettime(CLOCK_MONOTONIC) + $skipped };
+
+# Wait until no command left behind holds the lock any more and every one was reaped, as a killed
+# command can take a moment to exit, so each case starts from a clean state.
+my sub settle {
+    for (1 .. 100) {
+        PVE::Storage::StatusCommand::reap_pending();
+        sysopen(my $fh, $PVE::Storage::StatusCommand::LOCK_FILE, O_RDWR | O_CREAT) or die "$!\n";
+        my $free = flock($fh, LOCK_EX | LOCK_NB);
+        close($fh);
+        return if $free && PVE::Storage::StatusCommand::export_pending() eq "$$:";
+        select(undef, undef, undef, 0.1);
+    }
+    die "commands left behind did not end within 10 seconds\n";
+}
+
+# Let a stand-in waiting on the FIFO $path end, without hanging if none is waiting (yet).
+my sub release {
+    my ($path) = @_;
+    for (1 .. 100) {
+        if (sysopen(my $fh, $path, O_WRONLY | O_NONBLOCK)) {
+            close($fh);
+            return;
+        }
+        die "opening '$path' failed - $!\n" if !$!{ENXIO};
+        select(undef, undef, undef, 0.1);
+    }
+    die "nothing waits on '$path'\n";
+}
+
+my sub run_lines {
+    my ($cache, @cmd) = @_;
+    my $lines = [];
+    PVE::Storage::StatusCommand::run(
+        $cache, \@cmd,
+        outfunc => sub { push $lines->@*, $_[0] },
+        errfunc => sub { },
+    );
+    return $lines;
+}
+
+is_deeply(
+    run_lines({}, '/bin/sh', '-c', 'echo one; echo two; printf three'),
+    ['one', 'two', 'three'],
+    'output: every line, also an unterminated last one',
+);
+
+eval { run_lines({}, '/bin/sh', '-c', 'echo "vgs: no luck" >&2; exit 5') };
+is($@, "status command 'sh' failed: exit code 5 - vgs: no luck\n", 'failure: exit code and error');
+
+eval { run_lines({}, "$dir/missing") };
+like($@, qr/^status command 'missing' failed: exit code 127/, 'failure: a command that is missing');
+
+{
+    # each command takes 0.6 of the 1 second budget, by a clock that only moves when a command
+    # prints its line
+    local $PVE::Storage::StatusCommand::BUDGET = 1;
+    my $now = 0;
+    local $PVE::Storage::StatusCommand::CLOCK = sub { $now };
+    my $cache = {};
+    my $slow = sub {
+        PVE::Storage::StatusCommand::run(
+            $cache, ['/bin/echo', 'line'],
+            outfunc => sub { $now += 0.6 },
+            errfunc => sub { },
+        );
+    };
+    eval { $slow->() };
+    is($@, '', 'budget: a command within it succeeds');
+    eval { $slow->() };
+    like(
+        $@,
+        qr/not started - not enough time left \(shared limit: 1 seconds\)/,
+        'budget: shared by the commands of a pass',
+    );
+    my $err = $@;
+    eval { run_lines($cache, '/bin/true') };
+    is($@, $err, 'budget: and every later command of the pass fails with the first error');
+}
+settle();
+
+my $start = clock_gettime(CLOCK_MONOTONIC);
+eval { run_lines({}, '/bin/sh', '-c', 'exec >/dev/null 2>&1; exec sleep 30') };
+like(
+    $@,
+    qr/did not finish within the shared 1-second limit/,
+    'end of output: still waits for the exit, within the budget',
+);
+ok(clock_gettime(CLOCK_MONOTONIC) - $start < 10, 'end of output: and gives up at the deadline');
+settle();
+
+# a child of the command keeps the standard input, and with it the lock, after the command itself
+# was killed, like a command that cannot be killed while its I/O is stuck; it ends once the test
+# opens the FIFO for writing and closes it again
+my $release = "$dir/release";
+POSIX::mkfifo($release, 0600) or die "mkfifo failed - $!\n";
+my $holder = 'my $child = fork() // die; if ($child) { sleep 60 } else { $| = 1; print "ready\n";'
+    . ' open(my $fh, "<", $ARGV[0]) or die; <$fh> }';
+{
+    # the budget runs out once the child is in place, the real one only bounds a broken stand-in
+    local $PVE::Storage::StatusCommand::BUDGET = 30;
+    eval {
+        PVE::Storage::StatusCommand::run(
+            {}, [$^X, '-e', $holder, $release],
+            outfunc => sub { $skipped += 60 if $_[0] eq 'ready' },
+            errfunc => sub { },
+        );
+    };
+    like(
+        $@,
+        qr/did not finish within the shared 30-second limit/,
+        'left behind: the command is abandoned',
+    );
+}
+$start = clock_gettime(CLOCK_MONOTONIC);
+my $wait;
+{
+    # Check the reserve at timer installation, without relying on a sub-second scheduler margin.
+    my $timer = \&PVE::Tools::run_with_timeout;
+    no warnings 'redefine';
+    local *PVE::Tools::run_with_timeout = sub {
+        $wait = $_[0];
+        return scalar $timer->(@_);
+    };
+    eval { run_lines({}, '/bin/true') };
+}
+like(
+    $@,
+    qr/not started - not enough time left after waiting for another status command \(shared limit: 1 seconds\)/,
+    'descendant: inherited stdin lock uses the bounded wait',
+);
+my $elapsed = clock_gettime(CLOCK_MONOTONIC) - $start;
+ok(defined($wait) && $wait > 0 && $wait <= 0.5, 'descendant: reserves half a second');
+ok($elapsed >= 0.45 && $elapsed < 2, 'descendant: bounded wait without launching');
+release($release);
+settle();
+is_deeply(run_lines({}, '/bin/echo', 'ok'), ['ok'], 'left behind: works again once it ended');
+
+# a command stuck in I/O stays a child of this process; a child of the test stands in, until the
+# test closes the pipe it waits on
+pipe(my $hold_r, my $hold_w) or die "pipe failed - $!\n";
+my $child = fork() // die;
+if (!$child) {
+    close($hold_w);
+    sysread($hold_r, my $byte, 1);
+    POSIX::_exit(0);
+}
+close($hold_r);
+PVE::Storage::StatusCommand::import_pending("1:$child=vgs");
+is(PVE::Storage::StatusCommand::export_pending(), "$$:", 'hand-over: not from another process');
+PVE::Storage::StatusCommand::import_pending("$$:$child=vgs");
+is(PVE::Storage::StatusCommand::export_pending(), "$$:$child=vgs", 'hand-over: from this process');
+close($hold_w);
+settle();
+is(PVE::Storage::StatusCommand::export_pending(), "$$:", 'hand-over: reaped once it exited');
+
+{
+    local $PVE::Storage::StatusCommand::OUTPUT_LIMIT = 1000;
+    eval { run_lines({}, '/bin/sh', '-c', 'yes | head -c 5000') };
+    like($@, qr/wrote more than 1000 bytes/, 'output: bounded');
+}
+
+done_testing();
diff --git a/src/test/run_test_lvm_hints.pl b/src/test/run_test_lvm_hints.pl
index fc2ffff7..4eb2e224 100755
--- a/src/test/run_test_lvm_hints.pl
+++ b/src/test/run_test_lvm_hints.pl
@@ -21,9 +21,10 @@ my $CLASS = 'PVE::Storage::LVMPlugin';
 my ($hinted, $all, $vgs_fails, $pvscan_fails, @calls);
 
 my $lvm = Test::MockModule->new('PVE::Storage::LVMPlugin');
-$lvm->mock(
-    run_command => sub {
-        my ($cmd, %param) = @_;
+my $status_command = Test::MockModule->new('PVE::Storage::StatusCommand');
+$status_command->mock(
+    run_for_cache => sub {
+        my ($cache, $cmd, %param) = @_;
         if ($cmd->[0] eq '/sbin/vgs') {
             my $nohints = grep { $_ eq '--nohints' } @$cmd;
             push @calls, $nohints ? 'vgs --nohints' : 'vgs';
diff --git a/src/test/run_test_lvm_status.pl b/src/test/run_test_lvm_status.pl
new file mode 100755
index 00000000..9f335f63
--- /dev/null
+++ b/src/test/run_test_lvm_status.pl
@@ -0,0 +1,220 @@
+#!/usr/bin/perl
+
+# Test status collection for LVM and LVM-thin storages with the bounded status commands. If any
+# command fails, all LVM results of that pass are withdrawn, including earlier ones, and the failure
+# is reported once. No further LVM commands run during that pass; storages of other types are
+# unaffected. The tests use the real status pass with mocked commands, except for one case that runs
+# a real command through the real runner.
+
+use strict;
+use warnings;
+
+use lib '..';
+use File::Temp qw(tempdir);
+use Test::MockModule;
+use Test::More;
+
+use PVE::Storage;
+use PVE::Storage::ISCSIPlugin;
+use PVE::Storage::StatusCommand;
+
+# A hash that keeps the order of its keys, so the status pass goes through the storages in the
+# order a case needs.
+package OrderedHash {
+    sub TIEHASH { return bless { keys => [], values => {} }, $_[0]; }
+
+    sub STORE {
+        my ($self, $key, $value) = @_;
+        push $self->{keys}->@*, $key if !exists($self->{values}->{$key});
+        $self->{values}->{$key} = $value;
+    }
+    sub FETCH { return $_[0]->{values}->{ $_[1] }; }
+    sub EXISTS { return exists($_[0]->{values}->{ $_[1] }); }
+
+    sub DELETE {
+        my ($self, $key) = @_;
+        $self->{keys} = [grep { $_ ne $key } $self->{keys}->@*];
+        return delete($self->{values}->{$key});
+    }
+
+    sub CLEAR { $_[0]->{keys} = []; $_[0]->{values} = {}; }
+
+    sub FIRSTKEY {
+        $_[0]->{next} = 0;
+        return $_[0]->NEXTKEY();
+    }
+    sub NEXTKEY { return $_[0]->{keys}->[$_[0]->{next}++]; }
+    sub SCALAR { return scalar($_[0]->{keys}->@*); }
+}
+
+my $dir = tempdir(CLEANUP => 1);
+$PVE::Storage::StatusCommand::LOCK_FILE = "$dir/lock";
+
+my $cfg = {
+    ids => {
+        lvm1 => { type => 'lvm', vgname => 'vg1', content => { images => 1 } },
+        lvm2 => { type => 'lvm', vgname => 'vg2', content => { images => 1 } },
+        thin =>
+            { type => 'lvmthin', vgname => 'vg1', thinpool => 'pool', content => { images => 1 } },
+        local => { type => 'dir', path => $dir, content => { iso => 1 } },
+    },
+};
+
+# command outcomes of the pass by name ('vgs', 'vgs --nohints', 'pvscan', 'lvs', 'lvchange'): a
+# callback that dies, the lines to print, or { real => $cmd } to run $cmd through the real runner
+# instead
+my ($script, @calls);
+my $real_run = \&PVE::Storage::StatusCommand::run;
+my $status_command = Test::MockModule->new('PVE::Storage::StatusCommand');
+$status_command->mock(
+    run => sub {
+        my ($cache, $cmd, %param) = @_;
+        die $cache->{'status-command-error'} if defined($cache->{'status-command-error'});
+        my $name = $cmd->[0] =~ s|.*/||r;
+        $name .= ' --nohints' if grep { $_ eq '--nohints' } $cmd->@*;
+        push @calls, $name;
+        my $action = $script->{$name} // [];
+        return $real_run->($cache, $action->{real}, %param) if ref($action) eq 'HASH';
+        if (ref($action) eq 'CODE') {
+            eval { $action->() };
+            if (my $err = $@) {
+                $cache->{'status-command-error'} = $err;
+                die $err;
+            }
+            return 0;
+        }
+        $param{outfunc}->($_) for $action->@*;
+        return 0;
+    },
+);
+
+my sub pass {
+    my ($commands, $config) = @_;
+    ($script, @calls) = ($commands);
+    my @warnings;
+    local $SIG{__WARN__} = sub { push @warnings, $_[0] };
+    my $info = PVE::Storage::storage_info($config // $cfg);
+    return ($info, \@warnings);
+}
+
+# storage_info() goes through the storages in the key order of the configuration: the LVM
+# storages, then the thin pool, then an iSCSI storage
+my sub lvm_before_thin {
+    tie(my %ids, 'OrderedHash');
+    $ids{$_} = $cfg->{ids}->{$_} for qw(lvm1 lvm2 thin local);
+    $ids{san} = { type => 'iscsi', portal => '192.0.2.10', target => 'iqn.2003-01.test:t1' };
+    return { ids => \%ids };
+}
+
+my @activated;
+my $iscsi = Test::MockModule->new('PVE::Storage::ISCSIPlugin');
+$iscsi->mock(
+    check_connection => sub { return 1 },
+    activate_storage => sub { push @activated, $_[1]; return 1 },
+    status => sub { return (1024, 512, 512, 1) },
+);
+
+my $lvs = ['vg1:pool:1048576:twi-a-tz--::10.00:5.00::uuid::4096:0'];
+my ($info, $warnings) = pass({ vgs => ['vg1:1024:512:1', 'vg2:2048:1024:0'], lvs => $lvs });
+is_deeply(
+    [map { $info->{$_}->{active} } qw(lvm1 lvm2 thin local)],
+    [1, 1, 1, 1],
+    'all listings answer: every storage active',
+);
+is(scalar($warnings->@*), 0, 'and nothing is warned');
+
+($info, $warnings) = pass(
+    {
+        vgs => ['vg1:1024:512:1', 'vg2:2048:1024:0'],
+        lvs =>
+            sub { die "status command 'lvs' did not finish within the shared 15-second limit\n" },
+    },
+    lvm_before_thin(),
+);
+is_deeply([grep { m/^(vgs|lvs)$/ } @calls], ['vgs', 'lvs'], 'the LVM status is collected first');
+is_deeply(
+    [map { $info->{$_}->{active} } qw(lvm1 lvm2 thin)],
+    [0, 0, 0],
+    'a later listing fails: every LVM result of the pass is withdrawn, also those before',
+);
+is_deeply(\@activated, ['san'], 'a transport storage after the failure is still activated');
+is($info->{san}->{active}, 1, 'and active');
+is($info->{lvm1}->{total}, 0, 'with its sizes');
+is($info->{local}->{active}, 1, 'a storage of another kind is not affected');
+is_deeply(
+    $warnings,
+    [
+        "LVM status unavailable - status command 'lvs' did not finish within the shared 15-second limit\n"
+    ],
+    'the failure is warned once for all storages',
+);
+ok(!grep({ m/lvchange|pvscan/ } @calls), 'no LVM command after the failure');
+
+($info) = pass({ vgs => sub { die "another status command is still running\n" } });
+is_deeply(
+    [map { $info->{$_}->{active} } qw(lvm1 lvm2 thin local)],
+    [0, 0, 0, 1],
+    'the first listing fails: no LVM storage active',
+);
+is(scalar(grep { m/^vgs/ } @calls), 1, 'and only that one listing ran');
+
+($info, $warnings) = pass({
+    vgs => ['vg1:1024:512:1', 'vg2:2048:1024:0'],
+    lvs => ['vg1:pool:1048576:twi---tz--::10.00:5.00::uuid::4096:0'],
+    lvchange => sub { die "activating LV 'vg1/pool' failed: exit code 5\n" },
+});
+ok(grep({ $_ eq 'lvchange' } @calls), 'an inactive thin pool is activated');
+is_deeply(
+    [map { $info->{$_}->{active} } qw(lvm1 lvm2 thin local)],
+    [0, 0, 0, 1],
+    'its activation fails: every LVM result withdrawn',
+);
+is_deeply(
+    $warnings,
+    ["LVM status unavailable - activating LV 'vg1/pool' failed: exit code 5\n"],
+    'and warned once',
+);
+
+($info, $warnings) = pass({
+    vgs =>
+        { real => ['/bin/sh', '-c', 'echo vg1:1024:512:1; echo "vgs: device lost" >&2; exit 5'] },
+    lvs => $lvs,
+});
+is_deeply(
+    [map { $info->{$_}->{active} } qw(lvm1 lvm2 thin local)],
+    [0, 0, 0, 1],
+    'a real listing prints part of its output, then fails: no partial result is used',
+);
+is_deeply(
+    $warnings,
+    ["LVM status unavailable - status command 'sh' failed: exit code 5 - vgs: device lost\n"],
+    'and its error is warned once',
+);
+
+# the volume group of lvm2 is missing from the hinted list
+($info) = pass({ vgs => ['vg1:1024:512:1'], lvs => $lvs });
+is_deeply(
+    [map { $info->{$_}->{active} } qw(lvm1 lvm2 thin)],
+    [1, 0, 1],
+    'a volume group missing everywhere: only that storage inactive',
+);
+($info) = pass({
+    vgs => ['vg1:1024:512:1'],
+    'vgs --nohints' => ['vg1:1024:512:1', 'vg2:2048:1024:0'],
+    pvscan =>
+        sub { die "status command 'pvscan' did not finish within the shared 15-second limit\n" },
+    lvs => $lvs,
+});
+is_deeply(
+    [map { $info->{$_}->{active} } qw(lvm1 lvm2 thin)],
+    [0, 0, 0],
+    'the rebuild of the hints after a successful probe fails: every LVM result withdrawn',
+);
+
+my $plain;
+my $tools = Test::MockModule->new('PVE::Tools');
+$tools->mock(run_command => sub { $plain++; return 0 });
+PVE::Storage::LVMPlugin::lvm_vgs(undef, undef, {});
+ok($plain, 'without a status pass the listing runs as before');
+
+done_testing();
-- 
2.47.3




  parent reply	other threads:[~2026-11-06  0:55 UTC|newest]

Thread overview: 77+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-11-05 23:30 [PATCH v3 proxmox,proxmox-perl-rs,cluster,storage,docs,manager 00/76] SAN: cluster-wide multipath, SAN views and a guided storage setup Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 proxmox 01/76] add proxmox-multipath crate Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 proxmox-perl-rs 02/76] pve-rs: add PVE::RS::Multipath bindings for multipathd status parsing Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 cluster 03/76] cfs: observe the multipath configuration files Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 04/76] storage: distinguish status-only activation in plugins Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 05/76] lvm: find a shared volume group created by another node Thomas Lamprecht
2026-11-05 23:30 ` Thomas Lamprecht [this message]
2026-11-05 23:30 ` [PATCH v3 storage 07/76] iscsi: rate-limit session rescans per target Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 08/76] iscsi: list the LUNs of each session, not of its SCSI host Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 09/76] lvm: refuse to create a volume group on a device that holds data Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 10/76] lvm: refuse a base volume of a target that serves disk images Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 11/76] iscsi: refuse virtual devices other than multipath maps as volumes Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 12/76] diskmanage: collect disk transport type from lsblk Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 13/76] diskmanage: add helper to list multipath devices Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 14/76] diskmanage: qualify NVMe over fabrics transport Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 15/76] disks: list: add include-remote parameter Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 16/76] diskmanage: include iSCSI session devices in disk enumeration Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 17/76] diskmanage: link multipath member disks to their map device Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 18/76] iscsi: factor out session device map from device list Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 19/76] api: scan: add a SAN LUN inventory Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 20/76] pvesm: expose the " Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 21/76] disks: lvm: allow volume groups on SAN LUNs and multipath maps Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 22/76] multipath: add a library around multipathd Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 23/76] diskmanage: collect native NVMe multipath path state Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 24/76] api: scan: san-luns: report " Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 25/76] diskmanage: collect local block device users Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 26/76] api: add node-level iSCSI status and maintenance Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 27/76] api: iscsi: add session logout with last-path protection Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 28/76] multipath: add a cluster-wide configuration Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 29/76] iscsi: add an option to manage storage LUNs with multipath Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 30/76] lvm: activate a missing base volume before using the volume group Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 31/76] multipath: apply the configuration on every node Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 32/76] iscsi: share the initiator identity reader Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 33/76] multipath: publish per-node status Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 34/76] api: add endpoints for the cluster-wide multipath configuration Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 35/76] api: multipath: add the cluster-wide health status Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 36/76] multipath: collect identities of non-iSCSI SCSI paths Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 37/76] api: scan: san-luns: report the multipath state of each LUN Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 38/76] api: scan: flag duplicate physical volumes on distinct LUNs Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 39/76] api: disks: recheck local users and retain wipe device references Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 40/76] iscsi: name plain LUNs after their identity link Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 41/76] api: disks: guard SAN wipes with stable identities and storage checks Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 42/76] api: scan: refresh SAN usage with bounded probes Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 docs 43/76] storage: lvm: note the rescan after extending a shared volume group Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 docs 44/76] storage: document the cluster-wide multipath management Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 45/76] api: ceph: osd: keep iSCSI LUNs out of OSD creation Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 46/76] pvestatd: track pending storage status commands across re-exec Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 47/76] ui: storage: factor out the scan picker error mask Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 48/76] ui: storage: nfs: show scan errors and empty results Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 49/76] ui: storage: allow changing the node for NFS/CIFS scans Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 50/76] ui: storage: add guided remote storage wizard with NFS support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 51/76] ui: storage wizard: add SMB/CIFS support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 52/76] ui: storage wizard: preserve settings after partial setup Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 53/76] ui: storage wizard: add Fibre Channel shared LVM setup Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 54/76] ui: storage wizard: reconcile unanswered storage creation Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 55/76] ui: storage wizard: add ZFS over iSCSI support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 56/76] ui: dc: storage: add remote storage wizard entry to the add menu Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 57/76] ui: node: add SAN LUNs panel Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 58/76] ui: san luns: show multipath path state Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 59/76] api: nodes: expose iSCSI status and maintenance Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 60/76] pvenode: add iscsi commands Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 61/76] ui: node: san: add iSCSI target and session management Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 62/76] pvestatd: apply the multipath configuration and report the node status Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 63/76] api: cluster: add the multipath endpoints Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 64/76] ui: dc: add a cluster-wide multipath view Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 65/76] ui: storage: iscsi: add the multipath option Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 66/76] ui: san luns: show multipath state and manage LUN entries Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 67/76] ui: storage wizard: add iSCSI support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 68/76] ui: storage wizard: iscsi: show cluster initiator names Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 69/76] ui: storage wizard: iscsi: enable multipath for shared LVM Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 70/76] ui: node: san: wipe a LUN that no storage uses Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 71/76] ui: storage wizard: iscsi: reuse existing target connections Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 72/76] ui: storage wizard: fibre channel: enable multipath for a LUN Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 73/76] ui: storage wizard: let root@pam wipe a LUN with data Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 74/76] ui: storage wizard: do not offer LUNs in use for a new volume group Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 75/76] ui: san: show the local users of a LUN in its usage tooltip Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 76/76] ui: san: check the other nodes for users before wiping a LUN Thomas Lamprecht

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20261105233336.2178064-7-t.lamprecht@proxmox.com \
    --to=t.lamprecht@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal