diff --git a/src/PSProxmoxVE.Core/Services/GuestLifecycleService.cs b/src/PSProxmoxVE.Core/Services/GuestLifecycleService.cs
new file mode 100644
index 0000000..9166039
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Services/GuestLifecycleService.cs
@@ -0,0 +1,180 @@
+using System;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using PSProxmoxVE.Core.Authentication;
+using PSProxmoxVE.Core.Client;
+using PSProxmoxVE.Core.Exceptions;
+using PSProxmoxVE.Core.Models.Vms;
+using PSProxmoxVE.Core.Utilities;
+
+namespace PSProxmoxVE.Core.Services
+{
+ ///
+ /// Issues guest (VM/container) lifecycle operations and waits for them to actually take
+ /// effect: past the task PVE returns, and past the guest config lock the task's completion
+ /// does not account for. See docs/decisions/ ADR 0015 (the config-lock wait) and ADR 0020
+ /// (the flock retry implements).
+ ///
+ public class GuestLifecycleService : PveServiceBase
+ {
+ private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2);
+
+ private readonly Func _pollDelay;
+
+ /// Initializes a new instance that creates its own HTTP clients.
+ public GuestLifecycleService() : this(Sleep) { }
+
+ /// Initializes a new instance that uses the supplied HTTP client for all requests.
+ /// The HTTP client to use. The caller owns its lifetime.
+ public GuestLifecycleService(IPveHttpClient client) : this(client, Sleep) { }
+
+ ///
+ /// Test seam: same as but with the wait between
+ /// status polls replaceable, so a test can drive the loop without sleeping for it.
+ ///
+ /// Invoked with each poll interval instead of sleeping.
+ internal GuestLifecycleService(Func pollDelay)
+ {
+ _pollDelay = pollDelay ?? throw new ArgumentNullException(nameof(pollDelay));
+ }
+
+ ///
+ /// Test seam: same as but with the
+ /// wait between status polls replaceable.
+ ///
+ /// The HTTP client to use. The caller owns its lifetime.
+ /// Invoked with each poll interval instead of sleeping.
+ internal GuestLifecycleService(IPveHttpClient client, Func pollDelay) : base(client)
+ {
+ _pollDelay = pollDelay ?? throw new ArgumentNullException(nameof(pollDelay));
+ }
+
+ private static Task Sleep(TimeSpan duration)
+ {
+ Thread.Sleep(duration);
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Issues a guest operation and waits for the task it returns, reissuing the pair while
+ /// PVE rejects it for the guest's config flock.
+ ///
+ /// PVE takes that flock inside the worker for most guest operations, so the failure
+ /// surfaces as a failed task rather than a failed request and cannot be retried at the
+ /// HTTP layer. lock_config raises it before doing any work, so a reissue repeats
+ /// nothing.
+ ///
+ /// The authenticated PVE session.
+ /// The node the task runs on.
+ /// Issues the API call; invoked again on each retry.
+ ///
+ /// Invoked with a progress message before each reissue. A caller with somewhere to
+ /// report progress should pass one — a wait this long is otherwise indistinguishable
+ /// from a hang.
+ ///
+ /// The completed task, or the issued task when the call returned no UPID.
+ public PveTask InvokeGuestTask(
+ PveSession session,
+ string node,
+ Func issueOperation,
+ Action? onProgress = null)
+ {
+ if (issueOperation == null) throw new ArgumentNullException(nameof(issueOperation));
+
+ return Invoke(session, client =>
+ {
+ var taskService = new TaskService(client);
+ return GuestLockRetry.Execute(
+ () =>
+ {
+ var task = issueOperation();
+ return string.IsNullOrEmpty(task.Upid)
+ ? task
+ : taskService.WaitForTask(session, node, task.Upid);
+ },
+ onRetry: ex => onProgress?.Invoke($"Guest is locked, retrying: {ex.Message}"));
+ });
+ }
+
+ ///
+ /// Waits for a PVE task to complete, then polls guest status until it matches
+ /// and its config lock has cleared. Used by lifecycle
+ /// cmdlets (Start, Stop, Suspend, Resume, etc.) when -Wait is specified.
+ ///
+ /// The authenticated PVE session.
+ /// The cluster node name.
+ ///
+ /// Issues the lifecycle API call. Invoked again on each retry, so it must be safe to
+ /// repeat — see .
+ ///
+ /// The VM or container ID to poll.
+ /// The expected status string (e.g. "running", "stopped", "paused").
+ /// Maximum seconds to wait for the status transition. Default 60.
+ /// True to poll container status instead of VM status.
+ ///
+ /// Invoked with a progress message when a status poll fails and is retried, and
+ /// forwarded to for its own retry reporting.
+ ///
+ /// The completed task.
+ public PveTask WaitForStatusTransition(
+ PveSession session,
+ string node,
+ Func issueOperation,
+ int vmid,
+ string expectedStatus,
+ int timeoutSeconds = 60,
+ bool isContainer = false,
+ Action? onProgress = null)
+ {
+ var task = InvokeGuestTask(session, node, issueOperation, onProgress);
+
+ // We query the status/current endpoint directly instead of the list endpoint
+ // because it returns qmpstatus (needed for paused state detection — PVE reports
+ // status=running but qmpstatus=paused for suspended VMs).
+ var statusResource = isContainer
+ ? $"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/status/current"
+ : $"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/status/current";
+
+ var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
+ var lastMatched = false;
+
+ return Invoke(session, client =>
+ {
+ while (DateTime.UtcNow < deadline)
+ {
+ try
+ {
+ var json = client.GetAsync(statusResource).GetAwaiter().GetResult();
+ var snapshot = GuestStatusSnapshot.Evaluate(json, expectedStatus);
+ lastMatched = snapshot.StatusMatched;
+
+ // snapshot.Locked is the config `lock:` property (backup, clone, migrate,
+ // snapshot) — not the /var/lock/qemu-server flock, which PVE does not
+ // expose. The flock race is handled by retrying, not by waiting.
+ if (snapshot.StatusMatched && !snapshot.Locked)
+ return task;
+ }
+ catch (PveApiException ex) when (
+ ex.StatusCode != HttpStatusCode.Unauthorized
+ && ex.StatusCode != HttpStatusCode.Forbidden
+ && ex.StatusCode != HttpStatusCode.NotFound)
+ {
+ onProgress?.Invoke($"Status poll failed, retrying: {ex.Message}");
+ }
+
+ _pollDelay(PollInterval).GetAwaiter().GetResult();
+ }
+
+ // The guest still reports the expected status on the final poll and only the
+ // lock outlasted the deadline.
+ if (lastMatched)
+ return task;
+
+ throw new PveTaskTimeoutException(
+ task.Upid ?? "unknown",
+ TimeSpan.FromSeconds(timeoutSeconds));
+ });
+ }
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Utilities/CorosyncLinks.cs b/src/PSProxmoxVE.Core/Utilities/CorosyncLinks.cs
new file mode 100644
index 0000000..c56f192
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Utilities/CorosyncLinks.cs
@@ -0,0 +1,40 @@
+using System;
+using System.Collections.Generic;
+
+namespace PSProxmoxVE.Core.Utilities
+{
+ ///
+ /// Parses Corosync link strings (e.g. "link0=10.0.0.1") as sent to the cluster
+ /// create/join/add-node endpoints.
+ ///
+ public static class CorosyncLinks
+ {
+ ///
+ /// Parses an array of Corosync link strings into a dictionary.
+ ///
+ /// Array of link strings in "linkN=address" format.
+ ///
+ /// The parsed dictionary (null if is null, or if every entry
+ /// was malformed), and the malformed entries verbatim — the caller decides how to
+ /// report them.
+ ///
+ public static (Dictionary? Links, IReadOnlyList Malformed) Parse(string[]? links)
+ {
+ if (links == null) return (null, Array.Empty());
+
+ var result = new Dictionary();
+ var malformed = new List();
+ foreach (var link in links)
+ {
+ var parts = link?.Split(new[] { '=' }, 2) ?? Array.Empty();
+ if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1]))
+ {
+ malformed.Add(link ?? string.Empty);
+ continue;
+ }
+ result[parts[0].Trim()] = parts[1].Trim();
+ }
+ return (result.Count > 0 ? result : null, malformed);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs b/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs
index 4df1870..453f043 100644
--- a/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs
+++ b/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs
@@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.Management.Automation;
using PSProxmoxVE.Core.Authentication;
-using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Errors;
using PSProxmoxVE.Core.Exceptions;
using PSProxmoxVE.Core.Models.Vms;
@@ -204,52 +203,9 @@ namespace PSProxmoxVE.Cmdlets
int timeoutSeconds = 60,
bool isContainer = false)
{
- var task = InvokeGuestTask(session, node, issueOperation);
-
- // Then poll status/current until VM/container reaches the expected status.
- // We query the status/current endpoint directly instead of the list endpoint
- // because it returns qmpstatus (needed for paused state detection — PVE reports
- // status=running but qmpstatus=paused for suspended VMs).
- var statusResource = isContainer
- ? $"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/status/current"
- : $"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/status/current";
-
- var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
- var lastMatched = false;
- using var pollClient = new PveHttpClient(session);
- while (DateTime.UtcNow < deadline)
- {
- try
- {
- var json = pollClient.GetAsync(statusResource).GetAwaiter().GetResult();
- var snapshot = GuestStatusSnapshot.Evaluate(json, expectedStatus);
- lastMatched = snapshot.StatusMatched;
-
- // snapshot.Locked is the config `lock:` property (backup, clone, migrate,
- // snapshot) — not the /var/lock/qemu-server flock, which PVE does not
- // expose. The flock race is handled by retrying, not by waiting.
- if (snapshot.StatusMatched && !snapshot.Locked)
- return task;
- }
- catch (PSProxmoxVE.Core.Exceptions.PveApiException ex) when (
- ex.StatusCode != System.Net.HttpStatusCode.Unauthorized
- && ex.StatusCode != System.Net.HttpStatusCode.Forbidden
- && ex.StatusCode != System.Net.HttpStatusCode.NotFound)
- {
- WriteVerbose($"Status poll failed, retrying: {ex.Message}");
- }
-
- System.Threading.Thread.Sleep(2000);
- }
-
- // The guest still reports the expected status on the final poll and only the
- // lock outlasted the deadline.
- if (lastMatched)
- return task;
-
- throw new PveTaskTimeoutException(
- task.Upid ?? "unknown",
- TimeSpan.FromSeconds(timeoutSeconds));
+ return new GuestLifecycleService().WaitForStatusTransition(
+ session, node, issueOperation, vmid, expectedStatus, timeoutSeconds, isContainer,
+ onProgress: WriteVerbose);
}
///
@@ -267,18 +223,7 @@ namespace PSProxmoxVE.Cmdlets
/// The completed task, or the issued task when the call returned no UPID.
protected PveTask InvokeGuestTask(PveSession session, string node, Func issueOperation)
{
- if (issueOperation == null) throw new ArgumentNullException(nameof(issueOperation));
-
- var taskService = new TaskService();
- return GuestLockRetry.Execute(
- () =>
- {
- var task = issueOperation();
- return string.IsNullOrEmpty(task.Upid)
- ? task
- : taskService.WaitForTask(session, node, task.Upid);
- },
- onRetry: ex => WriteVerbose($"Guest is locked, retrying: {ex.Message}"));
+ return new GuestLifecycleService().InvokeGuestTask(session, node, issueOperation, onProgress: WriteVerbose);
}
///
@@ -304,20 +249,10 @@ namespace PSProxmoxVE.Cmdlets
/// Dictionary of parsed link entries, or null if input is null.
protected Dictionary? ParseLinks(string[]? links)
{
- if (links == null) return null;
-
- var result = new Dictionary();
- foreach (var link in links)
- {
- var parts = link.Split(new[] { '=' }, 2);
- if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1]))
- {
- WriteWarning($"Ignoring malformed link entry '{link}'. Expected format: 'link0=10.0.0.1'");
- continue;
- }
- result[parts[0].Trim()] = parts[1].Trim();
- }
- return result.Count > 0 ? result : null;
+ var (result, malformed) = CorosyncLinks.Parse(links);
+ foreach (var link in malformed)
+ WriteWarning($"Ignoring malformed link entry '{link}'. Expected format: 'link0=10.0.0.1'");
+ return result;
}
}
}
diff --git a/tests/PSProxmoxVE.Core.Tests/Services/GuestLifecycleServiceTests.cs b/tests/PSProxmoxVE.Core.Tests/Services/GuestLifecycleServiceTests.cs
new file mode 100644
index 0000000..4d65903
--- /dev/null
+++ b/tests/PSProxmoxVE.Core.Tests/Services/GuestLifecycleServiceTests.cs
@@ -0,0 +1,292 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Threading.Tasks;
+using Moq;
+using Xunit;
+using PSProxmoxVE.Core.Authentication;
+using PSProxmoxVE.Core.Client;
+using PSProxmoxVE.Core.Exceptions;
+using PSProxmoxVE.Core.Models.Vms;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Core.Tests.Services
+{
+ public class GuestLifecycleServiceTests
+ {
+ private const string TestNode = "pve1";
+ private const string TestUpid = "UPID:pve1:000ABC:00000001:5F1234AB:qmstart:100:root@pam:";
+ private const int VmId = 100;
+
+ private static PveSession CreateSession() =>
+ new PveSession("pve1.example.com", 8006, true, "PVE:root@pam:TEST_TOKEN");
+
+ private static PveTask IssuedTask() => new PveTask { Upid = TestUpid, Status = "running", Node = TestNode };
+
+ private const string TasksStatusFragment = "/tasks/";
+ private const string StoppedTaskJson = @"{ ""data"": { ""upid"": """ + TestUpid + @""", ""status"": ""stopped"", ""exitstatus"": ""OK"", ""user"": ""root@pam"" } }";
+
+ /// Stubs the task-status poll (issued by ) to resolve immediately.
+ private static void StubTaskCompletesImmediately(Mock mockClient)
+ {
+ mockClient.Setup(c => c.GetAsync(It.Is(s => s.Contains(TasksStatusFragment))))
+ .ReturnsAsync(StoppedTaskJson);
+ }
+
+ private static string GuestStatusJson(string status, bool locked = false)
+ {
+ var lockField = locked ? @",""lock"":""backup""" : "";
+ return $@"{{ ""data"": {{ ""status"":""{status}""{lockField} }} }}";
+ }
+
+ private static (GuestLifecycleService service, List delays) ServiceWithRecordedDelays(Mock mockClient)
+ {
+ var delays = new List();
+ var service = new GuestLifecycleService(mockClient.Object, d => { delays.Add(d); return Task.CompletedTask; });
+ return (service, delays);
+ }
+
+ [Fact]
+ public void WaitForStatusTransition_ReachesExpectedStatusOnSecondPoll_ReturnsTaskAndPollsQemuPath()
+ {
+ var mockClient = new Mock();
+ StubTaskCompletesImmediately(mockClient);
+ mockClient.SetupSequence(c => c.GetAsync(It.Is(s => s.Contains("status/current"))))
+ .ReturnsAsync(GuestStatusJson("stopped"))
+ .ReturnsAsync(GuestStatusJson("running"));
+
+ var (service, delays) = ServiceWithRecordedDelays(mockClient);
+
+ var task = service.WaitForStatusTransition(
+ CreateSession(), TestNode, IssuedTask, VmId, "running", timeoutSeconds: 60);
+
+ Assert.Equal(TestUpid, task.Upid);
+ var expectedPath = $"nodes/{TestNode}/qemu/{VmId}/status/current";
+ mockClient.Verify(c => c.GetAsync(expectedPath), Times.Exactly(2));
+ Assert.Single(delays);
+ }
+
+ [Fact]
+ public void WaitForStatusTransition_ContainerGuest_PollsLxcPath()
+ {
+ var mockClient = new Mock();
+ StubTaskCompletesImmediately(mockClient);
+ mockClient.Setup(c => c.GetAsync(It.Is(s => s.Contains("status/current"))))
+ .ReturnsAsync(GuestStatusJson("running"));
+
+ var (service, _) = ServiceWithRecordedDelays(mockClient);
+
+ var task = service.WaitForStatusTransition(
+ CreateSession(), TestNode, IssuedTask, VmId, "running", timeoutSeconds: 60, isContainer: true);
+
+ Assert.Equal(TestUpid, task.Upid);
+ var expectedPath = $"nodes/{TestNode}/lxc/{VmId}/status/current";
+ mockClient.Verify(c => c.GetAsync(expectedPath), Times.Once);
+ }
+
+ [Fact]
+ public void WaitForStatusTransition_StatusMatchedButLocked_KeepsPollingUntilLockClears()
+ {
+ var mockClient = new Mock();
+ StubTaskCompletesImmediately(mockClient);
+ mockClient.SetupSequence(c => c.GetAsync(It.Is(s => s.Contains("status/current"))))
+ .ReturnsAsync(GuestStatusJson("running", locked: true))
+ .ReturnsAsync(GuestStatusJson("running", locked: true))
+ .ReturnsAsync(GuestStatusJson("running", locked: false));
+
+ var (service, delays) = ServiceWithRecordedDelays(mockClient);
+
+ var task = service.WaitForStatusTransition(
+ CreateSession(), TestNode, IssuedTask, VmId, "running", timeoutSeconds: 60);
+
+ Assert.Equal(TestUpid, task.Upid);
+ var expectedPath = $"nodes/{TestNode}/qemu/{VmId}/status/current";
+ mockClient.Verify(c => c.GetAsync(expectedPath), Times.Exactly(3));
+ Assert.Equal(2, delays.Count);
+ }
+
+ [Fact]
+ public void WaitForStatusTransition_MatchedOnFinalPollButStillLocked_ReturnsTaskInsteadOfThrowing()
+ {
+ // ADR 0015: the guest reports the expected status right up to the deadline; only
+ // the lock outlasted the wait, and that must not fail the call.
+ var mockClient = new Mock();
+ StubTaskCompletesImmediately(mockClient);
+ mockClient.Setup(c => c.GetAsync(It.Is(s => s.Contains("status/current"))))
+ .ReturnsAsync(GuestStatusJson("running", locked: true));
+
+ var (service, _) = ServiceWithRecordedDelays(mockClient);
+
+ var task = service.WaitForStatusTransition(
+ CreateSession(), TestNode, IssuedTask, VmId, "running", timeoutSeconds: 1);
+
+ Assert.Equal(TestUpid, task.Upid);
+ }
+
+ [Fact]
+ public void WaitForStatusTransition_NeverMatches_ThrowsPveTaskTimeoutExceptionWithTheUpid()
+ {
+ var mockClient = new Mock();
+ StubTaskCompletesImmediately(mockClient);
+ mockClient.Setup(c => c.GetAsync(It.Is(s => s.Contains("status/current"))))
+ .ReturnsAsync(GuestStatusJson("stopped"));
+
+ var (service, _) = ServiceWithRecordedDelays(mockClient);
+
+ var ex = Assert.Throws(() =>
+ service.WaitForStatusTransition(
+ CreateSession(), TestNode, IssuedTask, VmId, "running", timeoutSeconds: 1));
+
+ Assert.Equal(TestUpid, ex.Upid);
+ }
+
+ [Fact]
+ public void WaitForStatusTransition_MatchedEarlierThenDriftedAway_StillTimesOut()
+ {
+ // ADR 0015: the fallback tests the most recent observation, not "matched at some
+ // point during the wait" — a guest that reached the expected status (here, while
+ // still locked, so the wait keeps going) and then drifted away has not satisfied
+ // the wait, even though it matched earlier.
+ var mockClient = new Mock();
+ StubTaskCompletesImmediately(mockClient);
+ var statusPath = $"nodes/{TestNode}/qemu/{VmId}/status/current";
+ var pollCount = 0;
+ mockClient.Setup(c => c.GetAsync(statusPath))
+ .ReturnsAsync(() =>
+ {
+ pollCount++;
+ return pollCount <= 2
+ ? GuestStatusJson("running", locked: true)
+ : GuestStatusJson("stopped");
+ });
+
+ var (service, _) = ServiceWithRecordedDelays(mockClient);
+
+ var ex = Assert.Throws(() =>
+ service.WaitForStatusTransition(
+ CreateSession(), TestNode, IssuedTask, VmId, "running", timeoutSeconds: 1));
+
+ Assert.Equal(TestUpid, ex.Upid);
+ Assert.True(pollCount > 2, $"expected more than two polls before the deadline, got {pollCount}");
+ }
+
+ [Fact]
+ public void WaitForStatusTransition_A500FromThePoll_IsRetriedAndReportedThroughOnProgress()
+ {
+ var mockClient = new Mock();
+ StubTaskCompletesImmediately(mockClient);
+ var statusPath = $"nodes/{TestNode}/qemu/{VmId}/status/current";
+ mockClient.SetupSequence(c => c.GetAsync(statusPath))
+ .ThrowsAsync(new PveApiException(HttpStatusCode.InternalServerError, "temporary failure", statusPath, "GET"))
+ .ReturnsAsync(GuestStatusJson("running"));
+
+ var (service, _) = ServiceWithRecordedDelays(mockClient);
+ var progressMessages = new List();
+
+ var task = service.WaitForStatusTransition(
+ CreateSession(), TestNode, IssuedTask, VmId, "running", timeoutSeconds: 60,
+ onProgress: progressMessages.Add);
+
+ Assert.Equal(TestUpid, task.Upid);
+ Assert.Single(progressMessages);
+ Assert.Contains("temporary failure", progressMessages[0]);
+ }
+
+ [Theory]
+ [InlineData(HttpStatusCode.Unauthorized)]
+ [InlineData(HttpStatusCode.Forbidden)]
+ [InlineData(HttpStatusCode.NotFound)]
+ public void WaitForStatusTransition_AnAuthOrNotFoundFailure_PropagatesInsteadOfRetrying(HttpStatusCode statusCode)
+ {
+ var mockClient = new Mock();
+ StubTaskCompletesImmediately(mockClient);
+ var statusPath = $"nodes/{TestNode}/qemu/{VmId}/status/current";
+ mockClient.Setup(c => c.GetAsync(statusPath))
+ .ThrowsAsync(new PveApiException(statusCode, "denied", statusPath, "GET"));
+
+ var (service, _) = ServiceWithRecordedDelays(mockClient);
+
+ var ex = Assert.Throws(() =>
+ service.WaitForStatusTransition(
+ CreateSession(), TestNode, IssuedTask, VmId, "running", timeoutSeconds: 60));
+
+ Assert.Equal(statusCode, ex.StatusCode);
+ mockClient.Verify(c => c.GetAsync(statusPath), Times.Once);
+ }
+
+ [Fact]
+ public void InvokeGuestTask_ReissuesOnGuestLockFailure()
+ {
+ var mockClient = new Mock();
+ StubTaskCompletesImmediately(mockClient);
+
+ var invocationCount = 0;
+ PveTask Issue()
+ {
+ invocationCount++;
+ if (invocationCount == 1)
+ throw new PveApiException(
+ HttpStatusCode.InternalServerError,
+ "can't lock file '/var/lock/qemu-server/lock-100.conf' - got timeout",
+ $"nodes/{TestNode}/qemu/{VmId}/config",
+ "PUT");
+ return IssuedTask();
+ }
+
+ var service = new GuestLifecycleService(mockClient.Object);
+ var progressMessages = new List();
+
+ var task = service.InvokeGuestTask(CreateSession(), TestNode, Issue, onProgress: progressMessages.Add);
+
+ Assert.Equal(TestUpid, task.Upid);
+ Assert.Equal(2, invocationCount);
+ Assert.Single(progressMessages);
+ }
+
+ [Fact]
+ public void InvokeGuestTask_TaskFailsOnGuestLock_ReissuesTheWholeOperation()
+ {
+ // ADR 0020: PVE takes the flock inside the worker for most guest operations, so
+ // the failure surfaces as a failed task rather than a failed request and the whole
+ // issue-and-wait pair must be reissued, not just the request.
+ var mockClient = new Mock();
+ const string lockFailedJson = @"{ ""data"": { ""upid"": """ + TestUpid + @""", ""status"": ""stopped"", "
+ + @"""exitstatus"": ""can't lock file '/var/lock/qemu-server/lock-100.conf' - got timeout"", "
+ + @"""user"": ""root@pam"" } }";
+ mockClient.SetupSequence(c => c.GetAsync(It.Is(s => s.Contains(TasksStatusFragment))))
+ .ReturnsAsync(lockFailedJson)
+ .ReturnsAsync(StoppedTaskJson);
+
+ var invocationCount = 0;
+ PveTask Issue()
+ {
+ invocationCount++;
+ return IssuedTask();
+ }
+
+ var service = new GuestLifecycleService(mockClient.Object);
+ var progressMessages = new List();
+
+ var task = service.InvokeGuestTask(CreateSession(), TestNode, Issue, onProgress: progressMessages.Add);
+
+ Assert.Equal(TestUpid, task.Upid);
+ Assert.Equal(2, invocationCount);
+ Assert.Single(progressMessages);
+ }
+
+ [Fact]
+ public void InvokeGuestTask_EmptyUpid_ReturnsTheIssuedTaskWithoutWaiting()
+ {
+ var mockClient = new Mock();
+ var issued = new PveTask { Upid = "", Status = "running", Node = TestNode };
+ PveTask Issue() => issued;
+
+ var service = new GuestLifecycleService(mockClient.Object);
+
+ var task = service.InvokeGuestTask(CreateSession(), TestNode, Issue);
+
+ Assert.Same(issued, task);
+ mockClient.Verify(c => c.GetAsync(It.IsAny()), Times.Never);
+ }
+ }
+}
diff --git a/tests/PSProxmoxVE.Core.Tests/Utilities/CorosyncLinksTests.cs b/tests/PSProxmoxVE.Core.Tests/Utilities/CorosyncLinksTests.cs
new file mode 100644
index 0000000..7eb540f
--- /dev/null
+++ b/tests/PSProxmoxVE.Core.Tests/Utilities/CorosyncLinksTests.cs
@@ -0,0 +1,70 @@
+using PSProxmoxVE.Core.Utilities;
+using Xunit;
+
+namespace PSProxmoxVE.Core.Tests.Utilities
+{
+ public class CorosyncLinksTests
+ {
+ [Fact]
+ public void Parse_NullInput_ReturnsNullDictionaryAndNoMalformedEntries()
+ {
+ var (links, malformed) = CorosyncLinks.Parse(null);
+
+ Assert.Null(links);
+ Assert.Empty(malformed);
+ }
+
+ [Fact]
+ public void Parse_WellFormedEntries_ReturnsTrimmedKeysAndValues()
+ {
+ var (links, malformed) = CorosyncLinks.Parse(new[] { "link0= 10.0.0.1 ", "link1=10.0.0.2" });
+
+ Assert.NotNull(links);
+ Assert.Equal("10.0.0.1", links!["link0"]);
+ Assert.Equal("10.0.0.2", links["link1"]);
+ Assert.Empty(malformed);
+ }
+
+ [Theory]
+ [InlineData("link0")]
+ [InlineData("link0=")]
+ [InlineData("=10.0.0.1")]
+ [InlineData("")]
+ public void Parse_MalformedEntry_IsReportedAndOmitted(string entry)
+ {
+ var (links, malformed) = CorosyncLinks.Parse(new[] { entry });
+
+ Assert.Null(links);
+ Assert.Equal(new[] { entry }, malformed);
+ }
+
+ [Fact]
+ public void Parse_NullEntry_IsReportedAsMalformedRatherThanThrowing()
+ {
+ var (links, malformed) = CorosyncLinks.Parse(new[] { "link0=10.0.0.1", null! });
+
+ Assert.NotNull(links);
+ Assert.Single(links!);
+ Assert.Equal(new[] { "" }, malformed);
+ }
+
+ [Fact]
+ public void Parse_ValueContainingEquals_SplitsOnlyOnTheFirstOne()
+ {
+ var (links, malformed) = CorosyncLinks.Parse(new[] { "link0=10.0.0.1=extra" });
+
+ Assert.NotNull(links);
+ Assert.Equal("10.0.0.1=extra", links!["link0"]);
+ Assert.Empty(malformed);
+ }
+
+ [Fact]
+ public void Parse_AllEntriesMalformed_ReturnsNullDictionaryWithEveryEntryReported()
+ {
+ var (links, malformed) = CorosyncLinks.Parse(new[] { "garbage", "link0=" });
+
+ Assert.Null(links);
+ Assert.Equal(new[] { "garbage", "link0=" }, malformed);
+ }
+ }
+}