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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.