fix: Import-PveOva's dead not-found catch and missing upload timeout (#180)

* fix: retarget Import-PveOva's not-found catch and add upload timeouts

VmService.GetVm throws InvalidOperationException when the VM is not yet
listed on the node, never PveApiException(NotFound) — the API call itself
returns 200. Import-PveOva's tail catch was for the exception GetVm never
throws, so a successful import without -Wait raised the InvalidOperationException
unhandled instead of falling back to a basic PveVm. Retarget the catch, and
narrow its try region to the GetVm call only so the fallback can no longer
fire for a WriteObject failure on an already-retrieved VM.

VmService.UploadOva and StorageService.UploadIso built their PveHttpClient
with no timeout override, so large OVA/ISO uploads inherited the session's
100s default and aborted mid-transfer. Both now take a TimeSpan? timeout
(default 30 minutes), matching Send-PveFile. Import-PveOva gains
-TimeoutSeconds mirroring Send-PveFileCmdlet's parameter.

Also drops the trailing null, null, null on WaitForTask calls in
ImportPveOvaCmdlet.cs and NewPveVmCmdlet.cs, left over from #140.

Closes #139

* fix: give UploadOva/UploadIso a timeout override and add coverage

Completes the #139 fix: VmService.UploadOva and StorageService.UploadIso
now take a TimeSpan? timeout (default 30 minutes) instead of always using
the session's 100s default. Adds a GetVm not-found regression test and a
timeout-propagation test suite for both upload methods.

* test: release the upload before deleting its temp file

The two default-timeout tests left the upload in flight and then
deleted the file it still had open. Windows refuses that, so both
build-and-test legs failed on windows-latest.

---------

Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
This commit is contained in:
goodolclint-claude[bot]
2026-09-02 20:29:46 +00:00
committed by GitHub
parent 0ec75a02e7
commit 716ecd100d
6 changed files with 271 additions and 20 deletions
@@ -113,6 +113,10 @@ namespace PSProxmoxVE.Core.Services
/// <param name="checksum">Optional checksum value.</param>
/// <param name="checksumAlgorithm">Optional checksum algorithm (e.g. "sha256").</param>
/// <param name="progressCallback">Optional callback with (bytesSent, totalBytes).</param>
/// <param name="timeout">
/// HTTP timeout override for this upload. Defaults to 30 minutes, overriding the
/// session's default 100-second timeout so that large files have time to transfer.
/// </param>
public PveTask UploadIso(
PveSession session,
string node,
@@ -120,7 +124,8 @@ namespace PSProxmoxVE.Core.Services
string filePath,
string? checksum = null,
string? checksumAlgorithm = null,
Action<long, long>? progressCallback = null)
Action<long, long>? progressCallback = null,
TimeSpan? timeout = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
@@ -132,7 +137,7 @@ namespace PSProxmoxVE.Core.Services
["content"] = "iso"
};
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session, timeout ?? TimeSpan.FromMinutes(30));
try
{
var response = client.UploadFileAsync(
+7 -2
View File
@@ -945,12 +945,17 @@ namespace PSProxmoxVE.Core.Services
/// Optional callback invoked periodically with (bytesSent, totalBytes).
/// May be called from a background thread.
/// </param>
/// <param name="timeout">
/// HTTP timeout override for this upload. Defaults to 30 minutes, overriding the
/// session's default 100-second timeout so that large OVA files have time to transfer.
/// </param>
public PveTask UploadOva(
PveSession session,
string node,
string storage,
string ovaPath,
Action<long, long>? progressCallback = null)
Action<long, long>? progressCallback = null,
TimeSpan? timeout = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
@@ -962,7 +967,7 @@ namespace PSProxmoxVE.Core.Services
["content"] = "import"
};
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session);
IPveHttpClient client = _injectedClient ?? new PveHttpClient(session, timeout ?? TimeSpan.FromMinutes(30));
try
{
var response = client.UploadFileAsync(
@@ -2,8 +2,6 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Management.Automation;
using System.Net;
using PSProxmoxVE.Core.Exceptions;
using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Services;
@@ -83,6 +81,15 @@ namespace PSProxmoxVE.Cmdlets.Vms
[Parameter(Mandatory = false, HelpMessage = "Wait for all tasks to complete before returning.")]
public SwitchParameter Wait { get; set; }
/// <summary>
/// HTTP timeout for the OVA upload, in seconds. Pass 0 for infinite (no timeout).
/// When omitted, defaults to 30 minutes — overriding the session timeout so that
/// large OVA uploads do not trip the default 100-second HttpClient timeout.
/// </summary>
[Parameter(Mandatory = false, HelpMessage = "HTTP timeout in seconds (0 = infinite). Defaults to 1800 (30 min).")]
[ValidateRange(0, int.MaxValue)]
public int? TimeoutSeconds { get; set; }
protected override void ProcessRecord()
{
// Validate the OVA file exists
@@ -164,11 +171,24 @@ namespace PSProxmoxVE.Cmdlets.Vms
$"Importing OVA to {Node}",
$"Uploading {fileName}...");
TimeSpan uploadTimeout;
if (TimeoutSeconds.HasValue)
{
uploadTimeout = TimeoutSeconds.Value == 0
? System.Threading.Timeout.InfiniteTimeSpan
: TimeSpan.FromSeconds(TimeoutSeconds.Value);
}
else
{
uploadTimeout = TimeSpan.FromMinutes(30);
}
long progressBytes = 0;
var uploadTask = System.Threading.Tasks.Task.Run(() =>
vmService.UploadOva(session, Node, Storage, Path,
(bytesSent, _) =>
System.Threading.Interlocked.Exchange(ref progressBytes, bytesSent)));
System.Threading.Interlocked.Exchange(ref progressBytes, bytesSent),
uploadTimeout));
// Poll progress on the pipeline thread
while (!uploadTask.IsCompleted)
@@ -193,7 +213,7 @@ namespace PSProxmoxVE.Cmdlets.Vms
if (!string.IsNullOrEmpty(uploadResult.Upid))
{
WriteVerbose("Waiting for OVA upload task to complete on PVE...");
var completedUpload = taskService.WaitForTask(session, Node, uploadResult.Upid, null, null, null);
var completedUpload = taskService.WaitForTask(session, Node, uploadResult.Upid);
if (completedUpload.ExitStatus != null && completedUpload.ExitStatus != "OK")
{
ThrowTerminatingError(new ErrorRecord(
@@ -278,7 +298,7 @@ namespace PSProxmoxVE.Cmdlets.Vms
if (Wait.IsPresent && !string.IsNullOrEmpty(createTask.Upid))
{
WriteVerbose("Waiting for VM creation + disk import to complete...");
var completedCreate = taskService.WaitForTask(session, Node, createTask.Upid, null, null, null);
var completedCreate = taskService.WaitForTask(session, Node, createTask.Upid);
if (completedCreate.ExitStatus != null && completedCreate.ExitStatus != "OK")
{
ThrowTerminatingError(new ErrorRecord(
@@ -292,23 +312,23 @@ namespace PSProxmoxVE.Cmdlets.Vms
// Step 8: Output the created VM
WriteVerbose("Retrieving created VM...");
PveVm? vm = null;
try
{
var vm = vmService.GetVm(session, Node, vmId);
WriteObject(vm);
vm = vmService.GetVm(session, Node, vmId);
}
catch (PveApiException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
catch (InvalidOperationException ex)
{
// VM not yet queryable (e.g. disk import still in progress); return basic info
WriteVerbose($"VM retrieval failed, returning basic info: {ex.Message}");
WriteObject(new PveVm
{
VmId = vmId,
Name = vmName,
Node = Node,
Status = "stopped"
});
}
WriteObject(vm ?? new PveVm
{
VmId = vmId,
Name = vmName,
Node = Node,
Status = "stopped"
});
}
}
}
@@ -255,7 +255,7 @@ namespace PSProxmoxVE.Cmdlets.Vms
if (Wait.IsPresent)
{
var taskService = new TaskService();
task = taskService.WaitForTask(session, Node, task.Upid, null, null, null);
task = taskService.WaitForTask(session, Node, task.Upid);
}
WriteObject(task);