* [PATCH qemu-server v5 3/3] fix #5032: agent: sync guest time via QGA when the clock falls behind
2026-08-13 13:57 [PATCH guest-common/manager/qemu-server v5 0/3] fix #5032: add guest time sync via QGA Jakob Klocker
2026-08-13 13:57 ` [PATCH pve-guest-common v5 1/3] AbstractConfig: allow passing options to snapshot_rollback Jakob Klocker
2026-08-13 13:57 ` [PATCH pve-manager v5 2/3] fix #5032: ui: qemu agent: add sync-time-on-resume option Jakob Klocker
@ 2026-08-13 13:57 ` Jakob Klocker
2 siblings, 0 replies; 4+ messages in thread
From: Jakob Klocker @ 2026-08-13 13:57 UTC (permalink / raw)
To: pve-devel; +Cc: Jakob Klocker
Add a 'sync-time-on-resume' agent config property (default off) that,
when enabled, issues the guest-set-time QGA command to set the guest
clock to the host's current time after operations that leave the clock
behind:
- resuming from hibernation (suspend-to-disk)
- resuming from pause/suspend
- rolling back to a snapshot that includes RAM
- live migration
- taking a snapshot, during which the guest is briefly frozen
The start, resume and rollback API endpoints gain a
'sync-time-on-resume' parameter (e.g. 'qm start <vmid>
--sync-time-on-resume 0') that overrides the configured agent value for
a single operation, forcing the sync on or off regardless of config.
guest_set_time prints a confirmation line to stdout on success. During
live migration the resume step runs over the migration tunnel, which
also uses stdout, so an unconditional print corrupts the tunnel
protocol. vm_resume therefore takes a 'quiet' parameter to suppress the
print on the migration path; the confirmation is logged by the migration
code instead.
A version check was added for migration to display a clear error
message in case 'sync-time-on-resume' is enabled and the target doesn't
support the 'sync-time-on-resume' the option.
Signed-off-by: Jakob Klocker <j.klocker@proxmox.com>
---
src/PVE/API2/Qemu.pm | 75 ++++++++++++++++++++++++++++++++--
src/PVE/CLI/qm.pm | 2 +-
src/PVE/QemuConfig.pm | 12 +++++-
src/PVE/QemuMigrate.pm | 13 ++++++
src/PVE/QemuServer.pm | 18 +++++++-
src/PVE/QemuServer/Agent.pm | 61 +++++++++++++++++++++++++++
src/PVE/QemuServer/RunState.pm | 11 ++++-
7 files changed, 183 insertions(+), 9 deletions(-)
diff --git a/src/PVE/API2/Qemu.pm b/src/PVE/API2/Qemu.pm
index 3320313c..d300ef0a 100644
--- a/src/PVE/API2/Qemu.pm
+++ b/src/PVE/API2/Qemu.pm
@@ -3542,6 +3542,24 @@ __PACKAGE__->register_method({
description => 'Whether to migrate conntrack entries for running VMs.',
},
'nets-host-mtu' => get_standard_option('pve-qm-nets-host-mtu'),
+ 'sync-time-on-resume' => {
+ type => 'boolean',
+ description =>
+ "Override the 'sync-time-on-resume' setting from the VM's guest agent (QGA)"
+ . " config for this start only. Controls whether the guest's clock is"
+ . " synchronized after the RAM state is restored. The guest agent must be"
+ . " enabled.",
+ verbose_description =>
+ "Override the 'sync-time-on-resume' setting from the VM's guest agent (QGA)"
+ . " config for this start only. When enabled, the 'guest-set-time' QEMU guest"
+ . " agent command is issued to synchronize the guest's clock to the host's current"
+ . " time after the RAM state is restored, which happens here when starting a VM"
+ . " that was hibernated (suspended to disk). Without synchronization the guest's"
+ . " clock may still reflect the time from when the VM was hibernated. The time is"
+ . " only synchronized when the QEMU Guest Agent option is enabled in the guest's"
+ . " configuration and the agent is running inside of the guest.",
+ optional => 1,
+ },
},
},
returns => {
@@ -3557,6 +3575,7 @@ __PACKAGE__->register_method({
my $vmid = extract_param($param, 'vmid');
my $timeout = extract_param($param, 'timeout');
my $machine = extract_param($param, 'machine');
+ my $sync_time = extract_param($param, 'sync-time-on-resume');
my $get_root_param = sub {
my $value = extract_param($param, $_[0]);
@@ -3663,6 +3682,7 @@ __PACKAGE__->register_method({
timeout => $timeout,
forcecpu => $force_cpu,
'nets-host-mtu' => $nets_host_mtu,
+ 'sync-time-on-resume' => $sync_time,
};
PVE::QemuServer::vm_start($storecfg, $vmid, $params, $migrate_opts);
@@ -4147,6 +4167,23 @@ __PACKAGE__->register_method({
description => "Do not check whether the VM is running, used"
. " internally during migration. Only root may use this option.",
},
+ 'sync-time-on-resume' => {
+ type => 'boolean',
+ description =>
+ "Override the 'sync-time-on-resume' setting from the VM's guest agent (QGA)"
+ . " config for this resume only. Controls whether the guest's clock is"
+ . " synchronized after the RAM state is restored. The guest agent must be"
+ . " enabled.",
+ verbose_description =>
+ "Override the 'sync-time-on-resume' setting from the VM's guest agent (QGA)"
+ . " config for this resume only. When enabled, the 'guest-set-time' QEMU guest"
+ . " agent command is issued to synchronize the guest's clock to the host's current"
+ . " time after resuming the VM from a paused state. Without synchronization the"
+ . " guest's clock may still reflect the time from when the VM was paused. The"
+ . " time is only synchronized when the QEMU Guest Agent option is enabled in the"
+ . " guest's configuration and the agent is running inside of the guest.",
+ optional => 1,
+ },
},
},
returns => {
@@ -4163,6 +4200,8 @@ __PACKAGE__->register_method({
my $vmid = extract_param($param, 'vmid');
+ my $sync_time = extract_param($param, 'sync-time-on-resume');
+
my $skiplock = extract_param($param, 'skiplock');
raise_param_exc({ skiplock => "Only root may use this option." })
if $skiplock && $authuser ne 'root@pam';
@@ -4193,10 +4232,14 @@ __PACKAGE__->register_method({
syslog('info', "resume VM $vmid: $upid\n");
if (!$to_disk_suspended) {
- PVE::QemuServer::RunState::vm_resume($vmid, $skiplock, $nocheck);
+ PVE::QemuServer::RunState::vm_resume($vmid, $skiplock, $nocheck, $sync_time);
} else {
my $storecfg = PVE::Storage::config();
- PVE::QemuServer::vm_start($storecfg, $vmid, { skiplock => $skiplock });
+ PVE::QemuServer::vm_start(
+ $storecfg,
+ $vmid,
+ { skiplock => $skiplock, 'sync-time-on-resume' => $sync_time },
+ );
}
return;
@@ -6385,6 +6428,24 @@ __PACKAGE__->register_method({
optional => 1,
default => 0,
},
+ 'sync-time-on-resume' => {
+ type => 'boolean',
+ description =>
+ "Override the 'sync-time-on-resume' setting from the VM's guest agent (QGA)"
+ . " config for this rollback only. Controls whether the guest's clock is"
+ . " synchronized after the RAM state is restored. The guest agent must be"
+ . " enabled.",
+ verbose_description =>
+ "Override the 'sync-time-on-resume' setting from the VM's guest agent (QGA)"
+ . " config for this rollback only. When enabled, the 'guest-set-time' QEMU guest"
+ . " agent command is issued to synchronize the guest's clock to the host's current"
+ . " time after rolling back to a snapshot that includes RAM. Without"
+ . " synchronization the guest's clock may still reflect the time from when the"
+ . " snapshot was taken. The time is only synchronized when the QEMU Guest Agent"
+ . " option is enabled in the guest's configuration and the agent is running"
+ . " inside of the guest.",
+ optional => 1,
+ },
},
},
returns => {
@@ -6404,6 +6465,8 @@ __PACKAGE__->register_method({
my $snapname = extract_param($param, 'snapname');
+ my $sync_time = extract_param($param, 'sync-time-on-resume');
+
# vm_start is invoked directly from the worker, so its own permissions
# predicate doesn't fire here - check VM.PowerMgmt up front so a user
# with only VM.Snapshot.Rollback can't power the VM on as a side effect.
@@ -6412,7 +6475,11 @@ __PACKAGE__->register_method({
my $realcmd = sub {
PVE::Cluster::log_msg('info', $authuser, "rollback snapshot VM $vmid: $snapname");
- PVE::QemuConfig->snapshot_rollback($vmid, $snapname);
+ PVE::QemuConfig->snapshot_rollback(
+ $vmid,
+ $snapname,
+ { 'sync-time-on-resume' => $sync_time },
+ );
if ($param->{start} && !PVE::QemuServer::Helpers::vm_running_locally($vmid)) {
PVE::API2::Qemu->vm_start({ vmid => $vmid, node => $node });
@@ -7068,7 +7135,7 @@ __PACKAGE__->register_method({
},
'resume' => sub {
if (PVE::QemuServer::Helpers::vm_running_locally($state->{vmid})) {
- PVE::QemuServer::RunState::vm_resume($state->{vmid}, 1, 1);
+ PVE::QemuServer::RunState::vm_resume($state->{vmid}, 1, 1, undef, 1);
} else {
die "VM $state->{vmid} not running\n";
}
diff --git a/src/PVE/CLI/qm.pm b/src/PVE/CLI/qm.pm
index b903c1f1..25351f64 100755
--- a/src/PVE/CLI/qm.pm
+++ b/src/PVE/CLI/qm.pm
@@ -473,7 +473,7 @@ __PACKAGE__->register_method({
if (PVE::QemuServer::Helpers::vm_running_locally($vmid)) {
# vm_resume with nocheck, since local node might not have processed config
# move/rename yet
- eval { PVE::QemuServer::RunState::vm_resume($vmid, 1, 1); };
+ eval { PVE::QemuServer::RunState::vm_resume($vmid, 1, 1, undef, 1); };
if ($@) {
$tunnel_write->("ERR: resume failed - $@");
} else {
diff --git a/src/PVE/QemuConfig.pm b/src/PVE/QemuConfig.pm
index 26f0fda2..e95a8c61 100644
--- a/src/PVE/QemuConfig.pm
+++ b/src/PVE/QemuConfig.pm
@@ -383,6 +383,11 @@ sub __snapshot_create_vol_snapshots_hook {
next;
}
}
+ my $conf = $class->load_config($vmid);
+ if (PVE::QemuServer::Agent::should_sync_time_on_resume($conf->{agent})) {
+ eval { PVE::QemuServer::Agent::guest_set_time($vmid); };
+ warn "could not sync guest time after snapshot - $@" if $@;
+ }
}
}
}
@@ -444,8 +449,9 @@ sub __snapshot_rollback_hook {
my ($class, $vmid, $conf, $snap, $prepare, $data) = @_;
if ($prepare) {
- # we save the machine of the current config
+ # we save the machine and agent of the current config
$data->{oldmachine} = $conf->{machine};
+ $data->{agent} = $conf->{agent};
} else {
# if we have a 'runningmachine' entry in the snapshot we use that
# for the forcemachine parameter, else we use the old logic
@@ -509,7 +515,7 @@ sub __snapshot_rollback_vm_stop {
}
sub __snapshot_rollback_vm_start {
- my ($class, $vmid, $vmstate, $data) = @_;
+ my ($class, $vmid, $vmstate, $data, $opts) = @_;
my $storecfg = PVE::Storage::config();
my $params = {
@@ -517,6 +523,8 @@ sub __snapshot_rollback_vm_start {
forcemachine => $data->{forcemachine},
forcecpu => $data->{forcecpu},
'nets-host-mtu' => $data->{'nets-host-mtu'},
+ agent => $data->{agent},
+ 'sync-time-on-resume' => $opts->{'sync-time-on-resume'},
};
PVE::QemuServer::vm_start($storecfg, $vmid, $params);
}
diff --git a/src/PVE/QemuMigrate.pm b/src/PVE/QemuMigrate.pm
index 8da6f15d..ae40195c 100644
--- a/src/PVE/QemuMigrate.pm
+++ b/src/PVE/QemuMigrate.pm
@@ -312,6 +312,16 @@ sub prepare {
}
}
+ my $target_version = PVE::QemuServer::Helpers::get_node_pvecfg_version($self->{node});
+ if (
+ $target_version
+ && !PVE::QemuServer::Helpers::pvecfg_min_version($target_version, 9, 2, 5)
+ && PVE::QemuServer::Agent::get_qga_key($conf, 'sync-time-on-resume')
+ ) {
+ die "target node '$self->{node}' is too old to support 'sync-time-on-resume'"
+ . " (needs pve-manager >= 9.2.5); disable the option before migrating\n";
+ }
+
my $vollist = PVE::QemuServer::get_vm_volumes($conf);
my $storages = {};
@@ -1770,6 +1780,9 @@ sub phase3_cleanup {
$self->{errors} = 1;
}
}
+ $self->log('info', "guest clock sync requested, will run on target after resume")
+ if !$self->{errors}
+ && PVE::QemuServer::Agent::should_sync_time_on_resume($conf->{agent});
}
if (
diff --git a/src/PVE/QemuServer.pm b/src/PVE/QemuServer.pm
index 2f43faa7..3fa7b5c8 100644
--- a/src/PVE/QemuServer.pm
+++ b/src/PVE/QemuServer.pm
@@ -5209,7 +5209,10 @@ sub vmconfig_update_agent {
die "skip\n" if !$conf->{$opt};
- my $hotplug_options = { fstrim_cloned_disks => 1 };
+ my $hotplug_options = {
+ fstrim_cloned_disks => 1,
+ 'sync-time-on-resume' => 1,
+ };
my $old_agent = parse_guest_agent($conf->{agent});
my $agent = parse_guest_agent($value);
@@ -6037,6 +6040,19 @@ sub vm_start_nolock {
);
}
+ # $resume = suspend-to-disk, $statefile = rollback with RAM. Rollback carries the snapshot's
+ # agent setting in params; hibernate resume falls back to the live config
+ if ($resume || ($statefile && !$migratedfrom)) {
+ my $agent = $params->{agent} // $conf->{agent};
+ if (PVE::QemuServer::Agent::should_sync_time_on_resume(
+ $agent,
+ $params->{'sync-time-on-resume'},
+ )) {
+ eval { PVE::QemuServer::Agent::guest_set_time($vmid); 1 };
+ warn "could not sync guest time after resume from saved state - $@" if $@;
+ }
+ }
+
return $res;
}
diff --git a/src/PVE/QemuServer/Agent.pm b/src/PVE/QemuServer/Agent.pm
index be6df443..35818b97 100644
--- a/src/PVE/QemuServer/Agent.pm
+++ b/src/PVE/QemuServer/Agent.pm
@@ -4,6 +4,8 @@ use v5.36;
use JSON;
use MIME::Base64 qw(decode_base64 encode_base64);
+use Time::HiRes qw(time);
+use POSIX qw(strftime);
use PVE::JSONSchema;
@@ -52,6 +54,25 @@ our $agent_fmt = {
optional => 1,
default => 1,
},
+ # FIXME: MAJOR VERSION: 10.0.0 - reconsider setting option on as default
+ 'sync-time-on-resume' => {
+ description =>
+ "Synchronize the guest's clock through QGA after operations that can leave"
+ . " the guest's clock behind.",
+ verbose_description =>
+ "Whether to issue the 'guest-set-time' QEMU guest agent command to synchronize the"
+ . " guest's clock to the host's current time after operations that can leave the guest's"
+ . " clock behind. This happens when resuming from hibernation, resuming from a paused"
+ . " state, taking a snapshot, after a migration, and after rolling back to a snapshot"
+ . " that includes RAM. In these cases the guest's clock may still reflect an earlier"
+ . " time. The time is only synchronized when the QEMU Guest Agent option is enabled"
+ . " in the guest's configuration and the agent is running inside of the guest. For"
+ . " resume, hibernation resume and snapshot rollback, the synchronization can be"
+ . " overridden per operation.",
+ type => 'boolean',
+ optional => 1,
+ default => 0,
+ },
type => {
description => "Select the agent type",
type => 'string',
@@ -332,4 +353,44 @@ sub guest_fs_freeze_applicable($agent_str, $vmid, $logfunc = undef) {
return 1;
}
+=head3 should_sync_time_on_resume
+
+Returns whether the guest's clock should be synchronized to the host's via the QEMU Guest Agent
+when the VM is resumed from saved state. Requires the guest agent to be enabled. An explicit
+C<$override> (from an API parameter) takes precedence over the C<sync-time-on-resume> agent
+config property when defined; otherwise the config value is used (defaulting to off). Does B<not>
+check whether the agent is actually running.
+
+=cut
+
+sub should_sync_time_on_resume($agent_str, $override = undef) {
+ my $agent = parse_guest_agent($agent_str);
+ return 0 if !$agent->{enabled};
+ return $override ? 1 : 0 if defined($override);
+ return $agent->{'sync-time-on-resume'} // 0;
+}
+
+=head3 guest_set_time
+
+Sets the guest's clock to the current host time via the QEMU Guest Agent's
+C<guest-set-time> command. The host time is passed explicitly (as nanoseconds
+since the UNIX epoch) because the argument-less form reads the time from the
+guest's RTC, which isn't supported on all platforms (e.g. Windows); passing
+the value directly works regardless of guest OS.
+
+=cut
+
+sub guest_set_time($vmid, $quiet = undef) {
+ if (!qga_check_running($vmid, 1)) {
+ print "skipping guest set time - agent configured but not running?\n" if !$quiet;
+ return;
+ }
+ my $time_ns = Time::HiRes::time() * 1_000_000_000;
+ my $res = PVE::QemuServer::Monitor::mon_cmd($vmid, 'guest-set-time', time => int($time_ns));
+ check_agent_error($res, "unable to set guest time");
+ my $time_str = POSIX::strftime("%F %T", localtime($time_ns / 1_000_000_000));
+ print "synced guest clock via guest agent to $time_str\n" if !$quiet;
+ return;
+}
+
1;
diff --git a/src/PVE/QemuServer/RunState.pm b/src/PVE/QemuServer/RunState.pm
index 889ddd28..c776b65a 100644
--- a/src/PVE/QemuServer/RunState.pm
+++ b/src/PVE/QemuServer/RunState.pm
@@ -131,7 +131,7 @@ sub vm_suspend($vmid, $skiplock = undef, $includestate = undef, $statestorage =
# $nocheck is set when called as part of a migration - in this context the
# location of the config file (source or target node) is not deterministic,
# since migration cannot wait for pmxcfs to process the rename
-sub vm_resume($vmid, $skiplock = undef, $nocheck = undef) {
+sub vm_resume($vmid, $skiplock = undef, $nocheck = undef, $sync_time = undef, $quiet = undef) {
PVE::QemuConfig->lock_config(
$vmid,
@@ -183,6 +183,15 @@ sub vm_resume($vmid, $skiplock = undef, $nocheck = undef) {
if $resume_cmd eq 'cont';
mon_cmd($vmid, $resume_cmd);
+
+ if (
+ $resume_cmd eq 'cont'
+ && PVE::QemuServer::Agent::should_sync_time_on_resume($conf->{agent},
+ $sync_time)
+ ) {
+ eval { PVE::QemuServer::Agent::guest_set_time($vmid, $quiet); };
+ warn "could not sync guest time after resume - $@" if $@;
+ }
},
);
}
--
2.47.3
^ permalink raw reply related [flat|nested] 4+ messages in thread