From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH pve-cluster 12/12] cfs: add runner executing cluster change hooks in children
Date: Thu, 22 Oct 2026 16:22:05 +0200 [thread overview]
Message-ID: <20261022142205.930536-13-h.laimer@proxmox.com> (raw)
In-Reply-To: <20261022142205.930536-1-h.laimer@proxmox.com>
The runner subscribes to the hook registry's path templates over one
notification socket connection and executes the runs the registry
derives from each event. Every run gets a forked child, so a hook that
dies or hangs cannot take down the runner or stall its connection.
The registry keys a run by its hook and parameters, and one run per
key is in flight at a time. Events for that key that arrive meanwhile
collapse into one rerun after the run ends. Runs of different keys go
in parallel up to a worker limit.
A run that runs too long stops counting against that limit, so the
other runs keep going. The next event for its key kills it and starts
a fresh run. A cap on all children keeps such runs from piling up. A
stop terminates its children and kills what has not exited after a
grace period. A child that survives even the kill is logged and left
behind, since waiting on it any longer has no point.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
debian/pve-cluster.install | 1 +
src/PVE/Cluster/HookRunner.pm | 268 ++++++++++++++++++++++++++++++
src/PVE/Cluster/Makefile | 2 +-
src/test/Makefile | 6 +-
src/test/hook_runner_test.pl | 302 ++++++++++++++++++++++++++++++++++
5 files changed, 577 insertions(+), 2 deletions(-)
create mode 100644 src/PVE/Cluster/HookRunner.pm
create mode 100644 src/test/hook_runner_test.pl
diff --git a/debian/pve-cluster.install b/debian/pve-cluster.install
index b8ebff7..8321f70 100644
--- a/debian/pve-cluster.install
+++ b/debian/pve-cluster.install
@@ -4,6 +4,7 @@ usr/bin/pmxcfs
usr/lib/
usr/share/man/man8/pmxcfs.8
usr/share/perl5/PVE/Cluster.pm
+usr/share/perl5/PVE/Cluster/HookRunner.pm
usr/share/perl5/PVE/Cluster/Hooks.pm
usr/share/perl5/PVE/Cluster/IPCConst.pm
usr/share/perl5/PVE/Cluster/Watch.pm
diff --git a/src/PVE/Cluster/HookRunner.pm b/src/PVE/Cluster/HookRunner.pm
new file mode 100644
index 0000000..f6fe34a
--- /dev/null
+++ b/src/PVE/Cluster/HookRunner.pm
@@ -0,0 +1,268 @@
+package PVE::Cluster::HookRunner;
+
+use strict;
+use warnings;
+
+use POSIX qw(WNOHANG);
+use Time::HiRes qw(sleep time);
+
+use PVE::Cluster;
+use PVE::Cluster::Hooks;
+use PVE::Cluster::Watch;
+use PVE::SafeSyslog;
+
+# Executes the runs the hook registry derives from cluster changes, one run
+# per key at a time in a forked child.
+
+my $stop_grace = 5;
+
+sub new {
+ my ($class, %param) = @_;
+
+ my $self = bless {
+ watch => $param{watch} // PVE::Cluster::Watch->new(),
+ max_workers => $param{max_workers} // 4,
+ stall_age => $param{stall_age} // 120,
+ running => {},
+ by_key => {},
+ pending => {},
+ queue => [],
+ queued => {},
+ terminate => 0,
+ fork_failed => 0,
+ }, $class;
+
+ return $self;
+}
+
+sub run {
+ my ($self) = @_;
+
+ local $SIG{TERM} = sub { $self->{terminate} = 1 };
+ local $SIG{INT} = $SIG{TERM};
+ # a child's exit has to interrupt the wait, under the default
+ # disposition the wait runs on to its timeout
+ local $SIG{CHLD} = sub { };
+
+ # a warning on stderr is lost in a daemon, so the watch's warnings about
+ # its connection and those of hooks in the children go to syslog
+ local $SIG{__WARN__} = sub {
+ my ($msg) = @_;
+ chomp $msg;
+ syslog('warning', $msg);
+ };
+
+ $self->{watch}->subscribe(PVE::Cluster::Hooks->subscription()->%*);
+
+ # the wait dies on a refused handshake. The children are stopped before
+ # the error propagates, so none of them outlives the runner
+ eval {
+ # with nothing running or queued only an event or a signal needs
+ # handling, and both end the wait early. Runs in flight need the short
+ # wait so a stall is noticed
+ while (!$self->{terminate}) {
+ $self->run_once($self->idle() ? 60 : 1);
+ }
+ };
+ my $err = $@;
+ $self->stop_children();
+ die $err if $err;
+
+ return;
+}
+
+sub run_once {
+ my ($self, $timeout) = @_;
+
+ for my $event ($self->{watch}->wait($timeout)) {
+ $self->schedule($_) for PVE::Cluster::Hooks->dispatch($event)->@*;
+ }
+
+ $self->reap();
+ $self->check_stalls();
+ $self->start_queued() if !$self->{terminate};
+
+ return;
+}
+
+sub stalled {
+ my ($self, $run) = @_;
+
+ return time() - $run->{started} >= $self->{stall_age};
+}
+
+sub check_stalls {
+ my ($self) = @_;
+
+ for my $pid (keys $self->{running}->%*) {
+ my $run = $self->{running}->{$pid};
+ if ($run->{killed_at} && time() - $run->{killed_at} > $stop_grace && !$run->{forced}) {
+ syslog('warning', "killing unresponsive hook run '$run->{key}' as $pid");
+ kill 'KILL', $pid;
+ $run->{forced} = 1;
+ } elsif (!$run->{stalled} && $self->stalled($run)) {
+ $run->{stalled} = 1;
+ my $age = int(time() - $run->{started});
+ syslog('warning', "hook run '$run->{key}' as $pid still running after ${age}s");
+ }
+ }
+
+ return;
+}
+
+sub idle {
+ my ($self) = @_;
+
+ return !scalar(keys $self->{running}->%*) && !scalar($self->{queue}->@*);
+}
+
+sub schedule {
+ my ($self, $run) = @_;
+
+ my $key = $run->{key};
+
+ if (my $pid = $self->{by_key}->{$key}) {
+ $self->{pending}->{$key} = $run;
+ my $current = $self->{running}->{$pid};
+ if ($self->stalled($current) && !$current->{killed_at}) {
+ syslog('warning', "replacing stalled hook run '$key' as $pid");
+ kill 'TERM', $pid;
+ $current->{killed_at} = time();
+ }
+ } elsif ($self->{queued}->{$key}) {
+ $self->{queued}->{$key} = $run;
+ } else {
+ push $self->{queue}->@*, $key;
+ $self->{queued}->{$key} = $run;
+ }
+
+ return;
+}
+
+# A stalled run gives its slot back so the other runs keep going, and the cap
+# on all runs keeps stalled ones from piling up.
+sub start_queued {
+ my ($self) = @_;
+
+ while (scalar($self->{queue}->@*)) {
+ my $running = scalar(keys $self->{running}->%*);
+ my $active = scalar(grep { !$_->{stalled} } values $self->{running}->%*);
+ last if $active >= $self->{max_workers} || $running >= 2 * $self->{max_workers};
+ my $key = shift $self->{queue}->@*;
+ my $run = delete $self->{queued}->{$key} // next;
+ next if $self->fork_run($run);
+
+ unshift $self->{queue}->@*, $key;
+ $self->{queued}->{$key} = $run;
+ last;
+ }
+
+ return;
+}
+
+sub reap {
+ my ($self) = @_;
+
+ for my $pid (keys $self->{running}->%*) {
+ my $res = waitpid($pid, WNOHANG);
+ next if $res == 0;
+
+ my $run = delete $self->{running}->{$pid};
+ delete $self->{by_key}->{ $run->{key} };
+
+ if ($run->{stalled}) {
+ my $age = int(time() - $run->{started});
+ syslog('info', "hook run '$run->{key}' ended after ${age}s");
+ }
+ # the child logs a hook that dies, so only a signal the runner did not
+ # send is logged here
+ if ($res == $pid && ($? & 127) && !$run->{killed_at}) {
+ syslog('err', "hook run '$run->{key}' killed by signal " . ($? & 127));
+ }
+
+ if (my $again = delete $self->{pending}->{ $run->{key} }) {
+ $self->schedule($again);
+ }
+ }
+
+ return;
+}
+
+# Returns whether the run was started.
+sub fork_run {
+ my ($self, $run) = @_;
+
+ my $pid = fork();
+ if (!defined($pid)) {
+ syslog('err', "fork for hook run '$run->{key}' failed: $!") if !$self->{fork_failed}++;
+ return 0;
+ }
+ $self->{fork_failed} = 0;
+
+ if ($pid == 0) {
+ $self->{watch}->close();
+ $SIG{$_} = 'DEFAULT' for qw(CHLD HUP INT TERM QUIT);
+
+ my $rc = 0;
+ eval {
+ # the hook reads the current state, the cache inherited from the
+ # parent may be older
+ PVE::Cluster::cfs_update();
+ $run->{hook}->{code}->($run->{param}, $run->{event});
+ };
+ if (my $err = $@) {
+ chomp $err;
+ syslog('err', "hook run '$run->{key}' failed: $err");
+ $rc = 1;
+ }
+ POSIX::_exit($rc);
+ }
+
+ $run->{started} = time();
+ $self->{running}->{$pid} = $run;
+ $self->{by_key}->{ $run->{key} } = $pid;
+
+ return 1;
+}
+
+sub reap_within_grace {
+ my ($self) = @_;
+
+ my $deadline = time() + $stop_grace;
+ while (scalar(keys $self->{running}->%*) && time() < $deadline) {
+ sleep(0.1);
+ $self->reap();
+ }
+
+ return;
+}
+
+sub stop_children {
+ my ($self) = @_;
+
+ my @pids = keys $self->{running}->%*;
+ return if !scalar(@pids);
+
+ my $now = time();
+ $_->{killed_at} = $now for values $self->{running}->%*;
+ kill 'TERM', @pids;
+ $self->reap_within_grace();
+
+ if (my @left = keys $self->{running}->%*) {
+ syslog('warning', "killing unresponsive hook runs: " . join(', ', @left));
+ kill 'KILL', @left;
+ # a child blocked on the cluster file system stays in
+ # uninterruptible sleep until pmxcfs answers, so the kill alone
+ # does not bound this wait
+ $self->reap_within_grace();
+
+ if (my @stuck = keys $self->{running}->%*) {
+ my $runs = join(', ', map { "'$self->{running}->{$_}->{key}' as $_" } @stuck);
+ syslog('warning', "hook runs left outstanding by the stop: $runs");
+ }
+ }
+
+ return;
+}
+
+1;
diff --git a/src/PVE/Cluster/Makefile b/src/PVE/Cluster/Makefile
index 0da7838..47666b2 100644
--- a/src/PVE/Cluster/Makefile
+++ b/src/PVE/Cluster/Makefile
@@ -1,6 +1,6 @@
PVEDIR=$(DESTDIR)/usr/share/perl5/PVE
-SOURCES=Hooks.pm IPCConst.pm Setup.pm Watch.pm
+SOURCES=HookRunner.pm Hooks.pm IPCConst.pm Setup.pm Watch.pm
.PHONY: install
install: $(SOURCES)
diff --git a/src/test/Makefile b/src/test/Makefile
index de07608..ab4eba2 100644
--- a/src/test/Makefile
+++ b/src/test/Makefile
@@ -4,7 +4,7 @@ cpgtest: cpgtest.c
gcc -Wall cpgtest.c $(shell pkg-config --cflags --libs libcpg libqb) -o cpgtest
.PHONY: check install clean distclean
-check: corosync-parser-test test-mac-prefix watch-client-test hooks-test
+check: corosync-parser-test test-mac-prefix watch-client-test hooks-test hook-runner-test
.PHONY: corosync-parser-test
corosync-parser-test:
@@ -22,5 +22,9 @@ watch-client-test:
hooks-test:
perl hooks_test.pl
+.PHONY: hook-runner-test
+hook-runner-test:
+ perl hook_runner_test.pl
+
distclean: clean
clean:
diff --git a/src/test/hook_runner_test.pl b/src/test/hook_runner_test.pl
new file mode 100644
index 0000000..571197c
--- /dev/null
+++ b/src/test/hook_runner_test.pl
@@ -0,0 +1,302 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+
+use lib '..';
+
+use File::Temp qw(tempdir);
+use Test::MockModule;
+use Test::More;
+use Time::HiRes qw(sleep time);
+
+use PVE::Cluster::Hooks;
+use PVE::Cluster::HookRunner;
+
+my $dir = tempdir(CLEANUP => 1);
+my $log = "$dir/runs";
+
+# The runner never talks to pmxcfs here. A scripted watch hands out event
+# batches as the daemon would deliver them, parameters included, and
+# cfs_update is stubbed. The forks and the bookkeeping around them are real.
+my $cluster_mock = Test::MockModule->new('PVE::Cluster');
+$cluster_mock->redefine(cfs_update => sub { });
+
+# nothing here belongs in the host's journal
+my @logged;
+my $runner_mock = Test::MockModule->new('PVE::Cluster::HookRunner');
+$runner_mock->redefine(syslog => sub { push @logged, [@_] });
+
+package FakeWatch {
+
+ sub new {
+ my ($class, @batches) = @_;
+ return bless { batches => [@batches] }, $class;
+ }
+
+ sub subscribe {
+ my ($self, %param) = @_;
+ $self->{subscription} = \%param;
+ return;
+ }
+
+ sub wait {
+ my ($self, $timeout) = @_;
+ warn delete($self->{warn}) if $self->{warn};
+ my $batch = shift $self->{batches}->@*;
+ return $batch->@* if $batch;
+ die $self->{fail} if $self->{fail};
+ Time::HiRes::sleep($timeout);
+ return;
+ }
+
+ sub close {
+ my ($self) = @_;
+ return;
+ }
+}
+
+my $record = sub {
+ my ($name, $param, $event) = @_;
+ open(my $fh, '>>', $log) or die "open: $!";
+ my $args = join(',', map { "$_=$param->{$_}" } sort keys $param->%*);
+ print $fh "$name($args) $event->{type}\n";
+ close($fh);
+ return;
+};
+
+PVE::Cluster::Hooks->register_hook({
+ name => 'guest',
+ path => 'nodes/{node}/qemu-server/{vmid}.conf',
+ code => sub {
+ $record->('guest', @_);
+ sleep(0.5);
+ },
+});
+
+PVE::Cluster::Hooks->register_hook({
+ name => 'dc',
+ path => 'datacenter.cfg',
+ code => sub {
+ $record->('dc', @_);
+ sleep(0.5);
+ },
+});
+
+PVE::Cluster::Hooks->register_hook({
+ name => 'broken',
+ path => 'broken.cfg',
+ code => sub { die "on purpose\n" },
+});
+
+# hangs on a rename, records only once it got past that
+PVE::Cluster::Hooks->register_hook({
+ name => 'slow',
+ path => 'slow/{id}.conf',
+ code => sub {
+ my ($param, $event) = @_;
+ sleep(30) if $event->{type} eq 'rename';
+ $record->('slow', @_);
+ },
+});
+
+my $drain = sub {
+ my ($runner) = @_;
+ my $deadline = time() + 10;
+ while (!$runner->idle() && time() < $deadline) {
+ $runner->run_once(0.05);
+ }
+ ok($runner->idle(), 'runner drained');
+ return;
+};
+
+my $lines = sub {
+ open(my $fh, '<', $log) or return [];
+ my @lines = <$fh>;
+ chomp @lines;
+ unlink($log);
+ return \@lines;
+};
+
+my $ev = sub {
+ my ($type, $path, $to, $params) = @_;
+ return {
+ type => $type,
+ path => $path,
+ ($to ? (to => $to) : ()),
+ ($params ? (params => $params) : ()),
+ };
+};
+
+my $guest = sub {
+ my ($vmid) = @_;
+ return { guest => [{ node => 'n1', vmid => $vmid }] };
+};
+
+# saves of one guest that arrive while the run for the first is still in
+# flight collapse into one run plus one rerun
+my $save = sub {
+ return [
+ $ev->(
+ 'rename',
+ 'nodes/n1/qemu-server/100.conf.tmp.1',
+ 'nodes/n1/qemu-server/100.conf',
+ $guest->(100),
+ ),
+ ];
+};
+my $watch = FakeWatch->new($save->(), $save->(), $save->());
+my $runner = PVE::Cluster::HookRunner->new(watch => $watch, max_workers => 4);
+$runner->run_once(0.05);
+is(scalar(keys $runner->{running}->%*), 1, 'one run in flight per key');
+$runner->run_once(0.05);
+$runner->run_once(0.05);
+is(scalar(keys $runner->{running}->%*), 1, 'later saves do not start a second run');
+is(scalar(keys $runner->{pending}->%*), 1, 'they are folded into one pending rerun');
+$drain->($runner);
+is_deeply(
+ $lines->(),
+ ['guest(node=n1,vmid=100) rename', 'guest(node=n1,vmid=100) rename'],
+ 'three saves became one run plus one rerun',
+);
+
+# different keys run concurrently up to the worker cap
+$watch = FakeWatch->new([
+ $ev->('write', 'nodes/n1/qemu-server/100.conf', undef, $guest->(100)),
+ $ev->('write', 'nodes/n1/qemu-server/101.conf', undef, $guest->(101)),
+ $ev->('write', 'nodes/n1/qemu-server/102.conf', undef, $guest->(102)),
+ $ev->('write', 'datacenter.cfg', undef, { dc => [{}] }),
+]);
+$runner = PVE::Cluster::HookRunner->new(watch => $watch, max_workers => 2);
+$runner->run_once(0.05);
+is(scalar(keys $runner->{running}->%*), 2, 'the worker cap holds');
+is(scalar($runner->{queue}->@*), 2, 'the rest waits in the queue');
+$drain->($runner);
+is_deeply(
+ [sort $lines->()->@*],
+ [
+ 'dc() write',
+ 'guest(node=n1,vmid=100) write',
+ 'guest(node=n1,vmid=101) write',
+ 'guest(node=n1,vmid=102) write',
+ ],
+ 'every key ran exactly once',
+);
+
+# a resync runs every hook once without parameters
+$watch = FakeWatch->new([$ev->('resync')]);
+$runner = PVE::Cluster::HookRunner->new(watch => $watch, max_workers => 4);
+$runner->run_once(0.05);
+$drain->($runner);
+is_deeply(
+ [sort $lines->()->@*],
+ ['dc() resync', 'guest() resync', 'slow() resync'],
+ 'resync reaches every hook, a failing hook does not disturb the others',
+);
+
+# the runner subscribes to what the registry derived
+$watch = FakeWatch->new();
+$runner = PVE::Cluster::HookRunner->new(watch => $watch, max_workers => 1);
+$runner->{terminate} = 1;
+$runner->run();
+is_deeply(
+ $watch->{subscription},
+ PVE::Cluster::Hooks->subscription(),
+ 'subscription is derived from the registered hooks',
+);
+
+# a watch that dies ends the runner, and the runner stops its children first
+$watch = FakeWatch->new([$ev->('write', 'datacenter.cfg', undef, { dc => [{}] })]);
+$watch->{fail} = "handshake refused\n";
+$runner = PVE::Cluster::HookRunner->new(watch => $watch, max_workers => 4);
+eval { $runner->run() };
+like($@, qr/handshake refused/, 'a failing watch ends the runner');
+is(scalar(keys $runner->{running}->%*), 0, 'and no child is left behind');
+$lines->();
+
+@logged = ();
+$watch = FakeWatch->new([$ev->('write', 'nodes/n1/qemu-server/100.conf', undef, $guest->(100))]);
+$runner = PVE::Cluster::HookRunner->new(watch => $watch, max_workers => 4);
+$runner->run_once(0.05);
+$runner->stop_children();
+$runner->run_once(0.05);
+is(scalar(keys $runner->{running}->%*), 0, 'the run was stopped');
+ok(!(grep { $_->[0] eq 'err' } @logged), 'a run interrupted by the stop is not an error');
+$lines->();
+
+@logged = ();
+$watch = FakeWatch->new();
+$watch->{warn} = "notification socket closed, reconnecting\n";
+$watch->{fail} = "stop\n";
+$runner = PVE::Cluster::HookRunner->new(watch => $watch, max_workers => 1);
+eval { $runner->run() };
+is_deeply(
+ [grep { $_->[0] eq 'warning' } @logged],
+ [['warning', 'notification socket closed, reconnecting']],
+ 'a warning from the watch is logged',
+);
+
+# a stalled run frees its slot, is logged, and is killed by the next event
+# for its key while the other keys keep running
+@logged = ();
+my $slow = sub {
+ my ($hang) = @_;
+ my $params = { slow => [{ id => 1 }] };
+ return $ev->('rename', 'slow/1.conf.tmp', 'slow/1.conf', $params) if $hang;
+ return $ev->('write', 'slow/1.conf', undef, $params);
+};
+$watch = FakeWatch->new(
+ [$slow->(1)],
+ [$ev->('write', 'datacenter.cfg', undef, { dc => [{}] })],
+ [$slow->()],
+);
+$runner = PVE::Cluster::HookRunner->new(watch => $watch, max_workers => 1, stall_age => 0.5);
+$runner->run_once(0.05);
+is(scalar(keys $runner->{running}->%*), 1, 'the hanging run holds the only slot');
+sleep(0.6);
+$runner->run_once(0.05);
+is(scalar(keys $runner->{running}->%*), 2, 'past the stall age another key runs beside it');
+ok(
+ (grep { $_->[0] eq 'warning' && $_->[1] =~ /^hook run 'slow:.*still running after/ } @logged),
+ 'the stall is logged',
+);
+$runner->run_once(0.05);
+$drain->($runner);
+is_deeply(
+ [sort $lines->()->@*],
+ ['dc() write', 'slow(id=1) write'],
+ 'the next event for the key replaced the stalled run with a rerun',
+);
+ok(
+ (grep { $_->[0] eq 'warning' && $_->[1] =~ /^replacing stalled hook run 'slow:/ } @logged),
+ 'and said so',
+);
+ok((grep { $_->[0] eq 'info' && $_->[1] =~ /ended after/ } @logged), 'the end is logged too');
+ok(!(grep { $_->[0] eq 'err' } @logged), 'a run the runner killed is not an error');
+
+# a child in uninterruptible sleep outlives the kill. A clock that jumps
+# past every deadline leaves the child unreaped the same way, without
+# waiting
+@logged = ();
+$watch = FakeWatch->new([$ev->('write', 'nodes/n1/qemu-server/100.conf', undef, $guest->(100))]);
+$runner = PVE::Cluster::HookRunner->new(watch => $watch, max_workers => 4);
+$runner->run_once(0.05);
+my ($stuck) = keys $runner->{running}->%*;
+my $clock = time();
+$runner_mock->redefine(time => sub () { $clock += 10 });
+$runner->stop_children();
+$runner_mock->unmock('time');
+is_deeply([keys $runner->{running}->%*], [$stuck], 'the stop gives up on a run it cannot reap');
+ok(
+ (
+ grep {
+ $_->[0] eq 'warning'
+ && $_->[1] =~ /^hook runs left outstanding by the stop: 'guest:/
+ } @logged
+ ),
+ 'and says which ones are left',
+);
+waitpid($stuck, 0);
+$lines->();
+
+done_testing();
--
2.47.3
prev parent reply other threads:[~2026-10-22 14:23 UTC|newest]
Thread overview: 13+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-10-22 14:21 [PATCH cluster 00/12] pmxcfs: add a change notification socket Hannes Laimer
2026-10-22 14:21 ` [PATCH pve-cluster 01/12] buildsys: add rust workspace under src/rust Hannes Laimer
2026-10-22 14:21 ` [PATCH pve-cluster 02/12] rust: notify: add path templates with placeholder capture Hannes Laimer
2026-10-22 14:21 ` [PATCH pve-cluster 03/12] rust: notify: add the wire protocol Hannes Laimer
2026-10-22 14:21 ` [PATCH pve-cluster 04/12] rust: notify: add subscription matching Hannes Laimer
2026-10-22 14:21 ` [PATCH pve-cluster 05/12] rust: notify: add the socket server Hannes Laimer
2026-10-22 14:21 ` [PATCH pve-cluster 06/12] rust: ffi: add C ABI staticlib for pmxcfs Hannes Laimer
2026-10-22 14:22 ` [PATCH pve-cluster 07/12] pmxcfs: memdb: add change notification hook Hannes Laimer
2026-10-22 14:22 ` [PATCH pve-cluster 08/12] buildsys: link pmxcfs against the rust notify staticlib Hannes Laimer
2026-10-22 14:22 ` [PATCH pve-cluster 09/12] pmxcfs: notify: emit change events over the notification socket Hannes Laimer
2026-10-22 14:22 ` [PATCH pve-cluster 10/12] cfs: add perl client for the change " Hannes Laimer
2026-10-22 14:22 ` [PATCH pve-cluster 11/12] cfs: add hook registry for change notification consumers Hannes Laimer
2026-10-22 14:22 ` Hannes Laimer [this message]
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=20261022142205.930536-13-h.laimer@proxmox.com \
--to=h.laimer@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