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
next prev 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