Files
PSProxmoxVE/tests/PSProxmoxVE.Core.Tests/Utilities/GuestLockRetryTests.cs
T
goodolclint-claude[bot] db416a3f03 fix: retry the qemu-server flock instead of predicting it
WaitForStatusTransition refused to return while snapshot.Locked, and its
comment quoted the exact error it was meant to prevent. Locked reads the
guest config's lock: property; the failure is the flock on
/var/lock/qemu-server/lock-<vmid>.conf, which PVE exposes nowhere.

The flock cannot be observed, so it is retried. GuestLockRetry reissues an
operation for a bounded 45s while PVE reports failing to enter lock_config
for a guest, which it raises before doing any work.

Two seams, because the failure has two surfaces. PveHttpClient.SendAsync
retries the request for operations PVE serialises in the API handler; it
takes a request factory because an HttpRequestMessage cannot be resent.
PveCmdletBase.InvokeGuestTask reissues the call and re-waits its task for
operations serialised in the forked worker, where the POST returns 200 and
only the task fails. WaitForStatusTransition routes through the latter,
hence Func<PveTask>.

The predicate is path-specific and anchored at the start of what PVE said:
lock_file uses identical wording for storage, LVM and HA locks, and a
qmclone that fails after allocating disks must not be reissued into
"VM already exists". That requires the raw text, so it reads
PveTaskFailedException.ExitStatus and PveApiException.ApiMessage.

The Locked check stays — it is correct for the config lock — with a comment
that says so.

Closes #113
2026-09-01 21:19:05 -05:00

161 lines
5.6 KiB
C#

using System;
using System.Net;
using System.Threading.Tasks;
using PSProxmoxVE.Core.Exceptions;
using PSProxmoxVE.Core.Utilities;
using Xunit;
namespace PSProxmoxVE.Core.Tests.Utilities
{
public class GuestLockRetryTests
{
private const string VmLockError =
"can't lock file '/var/lock/qemu-server/lock-100.conf' - got timeout";
private const string LxcLockError =
"can't lock file '/run/lock/lxc/pve-config-100.lock' - got timeout";
private static PveApiException ApiError(string message) =>
new PveApiException(HttpStatusCode.InternalServerError, message, "nodes/pve9a/qemu/100/config", "PUT");
private static PveTaskFailedException TaskError(string exitStatus) =>
new PveTaskFailedException("UPID:pve9a:00000001:qmreset:100:root@pam:", exitStatus);
[Fact]
public void IsLockTimeout_MatchesTheVmFlockErrorFromBothSurfaces()
{
Assert.True(GuestLockRetry.IsLockTimeout(ApiError(VmLockError)));
Assert.True(GuestLockRetry.IsLockTimeout(TaskError(VmLockError)));
}
[Fact]
public void IsLockTimeout_MatchesTheContainerFlockError()
{
Assert.True(GuestLockRetry.IsLockTimeout(TaskError(LxcLockError)));
}
[Theory]
[InlineData("VM 100 not running")]
[InlineData("can't lock file '/var/lock/qemu-server/lock-100.conf'")]
[InlineData("got timeout")]
// PVE::Tools::lock_file uses this same wording for locks that carry no
// reissue-safety guarantee. Only the two guest config paths may retry.
[InlineData("can't lock file '/run/lock/pve-manager/pve-storage-local' - got timeout")]
[InlineData("can't lock file '/var/lock/pve-manager/pve-backup' - got timeout")]
[InlineData("can't lock file '/run/lock/lvm/V_pve' - got timeout")]
// A message PVE prefixed with its own context means the worker had already
// started; reissuing it could repeat work that landed.
[InlineData("clone failed: can't lock file '/var/lock/qemu-server/lock-100.conf' - got timeout")]
[InlineData("unable to resize: can't lock file '/var/lock/qemu-server/lock-100.conf' - got timeout")]
public void IsLockTimeout_RejectsEveryOtherFailure(string message)
{
Assert.False(GuestLockRetry.IsLockTimeout(ApiError(message)));
Assert.False(GuestLockRetry.IsLockTimeout(TaskError(message)));
}
[Fact]
public void IsLockTimeout_ReadsWhatPveSaidRatherThanTheComposedMessage()
{
// Both exception types prefix Message with their own context, which would
// defeat the anchor if the predicate matched on Message.
var api = ApiError(VmLockError);
var task = TaskError(VmLockError);
Assert.StartsWith("PVE API error", api.Message);
Assert.StartsWith("Task UPID:", task.Message);
Assert.True(GuestLockRetry.IsLockTimeout(api));
Assert.True(GuestLockRetry.IsLockTimeout(task));
}
[Fact]
public void IsLockTimeout_RejectsExceptionTypesThatAreNotApiFailures()
{
Assert.False(GuestLockRetry.IsLockTimeout(new InvalidOperationException(VmLockError)));
}
[Fact]
public void Execute_ReturnsWithoutRetryingWhenTheOperationSucceeds()
{
var attempts = 0;
var result = GuestLockRetry.Execute(() => { attempts++; return 42; });
Assert.Equal(42, result);
Assert.Equal(1, attempts);
}
[Fact]
public void Execute_ReissuesUntilTheLockClears()
{
var attempts = 0;
var result = GuestLockRetry.Execute(() =>
{
attempts++;
if (attempts < 2) throw TaskError(VmLockError);
return "cloned";
});
Assert.Equal("cloned", result);
Assert.Equal(2, attempts);
}
[Fact]
public void Execute_DoesNotRetryOtherFailures()
{
var attempts = 0;
Assert.Throws<PveApiException>(() => GuestLockRetry.Execute<int>(() =>
{
attempts++;
throw ApiError("VM 100 not running");
}));
Assert.Equal(1, attempts);
}
[Fact]
public async Task ExecuteAsync_ReissuesUntilTheLockClears()
{
var attempts = 0;
var result = await GuestLockRetry.ExecuteAsync(() =>
{
attempts++;
if (attempts < 2) throw ApiError(VmLockError);
return Task.FromResult("written");
});
Assert.Equal("written", result);
Assert.Equal(2, attempts);
}
[Fact]
public async Task ExecuteAsync_DoesNotRetryOtherFailures()
{
var attempts = 0;
await Assert.ThrowsAsync<PveApiException>(() => GuestLockRetry.ExecuteAsync<int>(() =>
{
attempts++;
throw ApiError("can't lock file '/run/lock/lvm/V_pve' - got timeout");
}));
Assert.Equal(1, attempts);
}
[Fact]
public void Execute_GivesUpAndRethrowsOnceTheWindowElapses()
{
var attempts = 0;
var ex = Assert.Throws<PveTaskFailedException>(() => GuestLockRetry.Execute<int>(
() => { attempts++; throw TaskError(VmLockError); },
TimeSpan.Zero));
Assert.Contains("got timeout", ex.Message);
Assert.Equal(1, attempts);
}
}
}