File: IntegrationHostDiscoveryThreadPoolTests.cs
Web Access
Project: src\tests\Aspire.Hosting.RemoteHost.Tests\Aspire.Hosting.RemoteHost.Tests.csproj (Aspire.Hosting.RemoteHost.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text.Json;
using Aspire.Hosting.RemoteHost.Ats;
using Aspire.Hosting.RemoteHost.Language;
using Aspire.Hosting.RemoteHost.Tests.TestServices;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting.Internal;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
 
namespace Aspire.Hosting.RemoteHost.Tests;
 
[CollectionDefinition(nameof(ThreadPoolSaturationCollection), DisableParallelization = true)]
public sealed class ThreadPoolSaturationCollection;
 
// Changes process-wide thread pool limits, so it must not run alongside other tests.
[Collection(nameof(ThreadPoolSaturationCollection))]
public class IntegrationHostDiscoveryThreadPoolTests
{
    [Fact]
    public async Task InitializeHostsAsync_CompletesWhileSynchronousTestsOccupyEverySchedulerSlot()
    {
        // Run on a dedicated thread so waiting here never consumes a pool worker.
        var (slotsOccupied, discovered) = await Task.Factory.StartNew(
            RunDiscoveryWhilePoolIsSaturated,
            CancellationToken.None,
            TaskCreationOptions.LongRunning,
            TaskScheduler.Default);
 
        Assert.True(slotsOccupied && discovered,
            $"Capability discovery could not complete while {TestThreadPoolHeadroom.SynchronousTestSlots} synchronous tests " +
            $"occupied the thread pool (minimum workers: {GetMinWorkerThreads()}, slots occupied: {slotsOccupied}, discovered: {discovered}).");
    }
 
    private static (bool SlotsOccupied, bool Discovered) RunDiscoveryWhilePoolIsSaturated()
    {
        ThreadPool.GetMaxThreads(out var maxWorkerThreads, out var maxCompletionPortThreads);
        var minWorkerThreads = GetMinWorkerThreads();
        var slots = TestThreadPoolHeadroom.SynchronousTestSlots;
 
        // Windows CI showed the pool staying at its floor while CPU-bound synchronous tests kept
        // completing, so hill climbing never injected workers. Cap the pool at the floor to model
        // that deterministically instead of depending on host-specific injection timing.
        Assert.True(ThreadPool.SetMaxThreads(Math.Max(minWorkerThreads, Environment.ProcessorCount), maxCompletionPortThreads));
        var releaseSlots = new ManualResetEventSlim();
        var slotsOccupied = new CountdownEvent(slots);
        var occupiedSlots = new Task[slots];
        var discovery = Task.CompletedTask;
        try
        {
            for (var i = 0; i < slots; i++)
            {
                // Queued from a dedicated (non-pool) thread, so these land in the global pool queue.
                occupiedSlots[i] = Task.Factory.StartNew(() =>
                {
                    slotsOccupied.Signal();
                    releaseSlots.Wait(TimeSpan.FromSeconds(30));
                }, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
            }
            // Without headroom some slots cannot start because test infrastructure already holds a
            // worker; any slots still queued then sit ahead of the discovery continuations.
            var occupied = slotsOccupied.Wait(TimeSpan.FromSeconds(5));
 
            discovery = RunDiscovery();
            return (occupied, discovery.Wait(TimeSpan.FromSeconds(10)));
        }
        finally
        {
            releaseSlots.Set();
            ThreadPool.SetMaxThreads(maxWorkerThreads, maxCompletionPortThreads);
            // Only dispose once every slot has finished using the events.
            if (Task.WaitAll(occupiedSlots, TimeSpan.FromSeconds(30)))
            {
                releaseSlots.Dispose();
                slotsOccupied.Dispose();
            }
            // A timed-out wait does not stop discovery; drain it before other tests can run.
            discovery.GetAwaiter().GetResult();
        }
    }
 
    private static async Task RunDiscovery()
    {
        using var services = new ServiceCollection().BuildServiceProvider();
        using var registry = new ExternalCapabilityRegistry(NullLogger<ExternalCapabilityRegistry>.Instance);
        await using var launcher = IntegrationHostTestServices.CreateLauncher(
            new LanguageSupportResolver(services, () => [], NullLogger<LanguageSupportResolver>.Instance),
            registry, new ConfigurationBuilder().Build(),
            new ApplicationLifetime(NullLogger<ApplicationLifetime>.Instance), NullLogger<IntegrationHostLauncher>.Instance);
        using var firstConnection = new IntegrationHostTestConnection(JsonSerializer.SerializeToElement(new[]
        {
            new { id = "test/first", method = "first", returnType = new { typeId = "void", category = "Primitive" } }
        }));
        using var secondConnection = new IntegrationHostTestConnection(JsonSerializer.SerializeToElement(new[]
        {
            new { id = "test/second", method = "second", returnType = new { typeId = "void", category = "Primitive" } }
        }));
        registry.AddIntegrationHost(firstConnection.ServerRpc);
        registry.AddIntegrationHost(secondConnection.ServerRpc);
 
        await launcher.InitializeHostsAsync(2, Timeout.InfiniteTimeSpan, TimeSpan.FromSeconds(30), CancellationToken.None);
 
        Assert.True(registry.IsRegistered("test/first"));
        Assert.True(registry.IsRegistered("test/second"));
    }
 
    private static int GetMinWorkerThreads()
    {
        ThreadPool.GetMinThreads(out var workerThreads, out _);
        return workerThreads;
    }
}