Files
PSProxmoxVE/tests/PSProxmoxVE.Core.Tests/Client/PveHandlerCacheTests.cs
T
goodolclint-claude[bot] 907b2aa1f2 refactor: share one transport per host and poll tasks with backoff (#151) (#193)
Every service method built and disposed its own PveHttpClient, and each
client owned a fresh HttpClientHandler, so every API call was a TCP connect
plus a TLS handshake and WaitForTask paid that 300 times over a ten-minute
wait.

PveServiceBase now owns the injected-or-fresh client lifetime behind one
Invoke helper; the 201 hand-written try/finally blocks across the 16
services collapse to calls on it, and the nested NodeService/VmService
instances receive the injected client. PveHttpClient takes its handler
from a process-wide PveHandlerCache keyed on (host, port,
skipCertificateCheck) and never disposes it, so the connection pool
outlives any one client. WaitForTask holds one client for the whole wait
and, when no pollInterval is supplied, backs off from 1 s toward a 10 s
cap, never sleeping past the deadline.

Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
2026-09-02 22:55:38 +00:00

145 lines
4.7 KiB
C#

using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
using Xunit;
namespace PSProxmoxVE.Core.Tests.Client
{
public class PveHandlerCacheTests
{
private static PveSession NewSession(bool skipCertificateCheck = true, string host = "pve.example.com") =>
new PveSession(host, 8006, skipCertificateCheck,
"root@pam!token=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
private sealed class CannedHandler : HttpClientHandler
{
public int Sends;
public bool IsDisposed;
protected override void Dispose(bool disposing)
{
IsDisposed = true;
base.Dispose(disposing);
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (IsDisposed) throw new ObjectDisposedException(nameof(CannedHandler));
Interlocked.Increment(ref Sends);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"data\":{\"version\":\"8.2\"}}")
});
}
}
private static (PveHandlerCache cache, List<CannedHandler> built) NewCache()
{
var built = new List<CannedHandler>();
var cache = new PveHandlerCache(_ =>
{
var h = new CannedHandler();
built.Add(h);
return h;
});
return (cache, built);
}
[Fact]
public void TwoClientsForTheSameEndpointShareOneHandler()
{
var (cache, built) = NewCache();
using var first = new PveHttpClient(NewSession(), cache);
using var second = new PveHttpClient(NewSession(), cache);
Assert.Single(built);
Assert.Same(first.Handler, second.Handler);
Assert.Equal(1, cache.Count);
}
[Fact]
public void DifferentSkipCertificateCheckGetsADifferentHandler()
{
var (cache, built) = NewCache();
using var insecure = new PveHttpClient(NewSession(skipCertificateCheck: true), cache);
using var verified = new PveHttpClient(NewSession(skipCertificateCheck: false), cache);
Assert.Equal(2, built.Count);
Assert.NotSame(insecure.Handler, verified.Handler);
}
[Fact]
public void DifferentHostGetsADifferentHandler()
{
var (cache, _) = NewCache();
using var a = new PveHttpClient(NewSession(host: "pve-a.example.com"), cache);
using var b = new PveHttpClient(NewSession(host: "pve-b.example.com"), cache);
Assert.NotSame(a.Handler, b.Handler);
}
[Fact]
public async Task DisposingOneClientLeavesTheSharedHandlerUsableByAnother()
{
var (cache, built) = NewCache();
var first = new PveHttpClient(NewSession(), cache);
using var second = new PveHttpClient(NewSession(), cache);
first.Dispose();
var body = await second.GetAsync("version");
Assert.Contains("8.2", body);
Assert.Equal(1, built[0].Sends);
Assert.Single(built);
Assert.False(built[0].IsDisposed);
}
[Fact]
public void AnExplicitHandlerBypassesTheCacheAndIsOwnedByTheClient()
{
var (cache, built) = NewCache();
var own = new CannedHandler();
var client = new PveHttpClient(NewSession(), timeoutOverride: null,
guestLockRetryWindow: null, handler: own, guestLockRetryDelay: null, handlerCache: cache);
Assert.Same(own, client.Handler);
Assert.Empty(built);
client.Dispose();
Assert.True(own.IsDisposed);
}
[Fact]
public void SharedHandlersDoNotCarryACookieContainer()
{
var handler = PveHandlerCache.Shared.Get("cookies.example.invalid", 8006, skipCertificateCheck: false);
Assert.False(handler.UseCookies);
}
[Fact]
public void GetBuildsEachKeyOnceUnderConcurrentCallers()
{
var (cache, built) = NewCache();
var handlers = new HttpClientHandler[32];
Parallel.For(0, handlers.Length, i =>
handlers[i] = cache.Get("pve.example.com", 8006, skipCertificateCheck: true));
Assert.Single(built);
Assert.All(handlers, h => Assert.Same(built[0], h));
}
}
}