Skip to content

Replace WebHostBuilder with HostBuilder pattern in MVC folder #62703

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jul 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions src/Hosting/Hosting/src/GenericHost/GenericWebHostBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ namespace Microsoft.AspNetCore.Hosting;

internal sealed class GenericWebHostBuilder : WebHostBuilderBase, ISupportsStartup
{
private object? _startupObject;
private const string _startupConfigName = "__UseStartup.StartupObject";
private readonly object _startupKey = new object();

private AggregateException? _hostingStartupErrors;
Expand Down Expand Up @@ -170,13 +170,15 @@ public IWebHostBuilder UseStartup([DynamicallyAccessedMembers(StartupLinkerOptio
UseSetting(WebHostDefaults.ApplicationKey, startupAssemblyName);

// UseStartup can be called multiple times. Only run the last one.
_startupObject = startupType;
_builder.Properties[_startupConfigName] = startupType;

_builder.ConfigureServices((context, services) =>
{
// Run this delegate if the startup type matches
if (object.ReferenceEquals(_startupObject, startupType))
if (_builder.Properties.TryGetValue(_startupConfigName, out var startupObject) &&
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug introduced by #24144 that allowed multiple startups to run if they spanned different IWebHostBuilder instances. See test MultipleConfigureWebHostCallsWithUseStartupLastWins below for example.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Crazy

object.ReferenceEquals(startupObject, startupType))
{
_builder.Properties.Remove(_startupConfigName);
UseStartup(startupType, context, services);
}
});
Expand All @@ -193,16 +195,18 @@ public IWebHostBuilder UseStartup([DynamicallyAccessedMembers(StartupLinkerOptio
UseSetting(WebHostDefaults.ApplicationKey, startupAssemblyName);

// Clear the startup type
_startupObject = startupFactory;
_builder.Properties[_startupConfigName] = startupFactory;

_builder.ConfigureServices(ConfigureStartup);

[UnconditionalSuppressMessage("Trimmer", "IL2072", Justification = "Startup type created by factory can't be determined statically.")]
void ConfigureStartup(HostBuilderContext context, IServiceCollection services)
{
// UseStartup can be called multiple times. Only run the last one.
if (object.ReferenceEquals(_startupObject, startupFactory))
if (_builder.Properties.TryGetValue(_startupConfigName, out var startupObject) &&
object.ReferenceEquals(startupObject, startupFactory))
{
_builder.Properties.Remove(_startupConfigName);
var webHostBuilderContext = GetWebHostBuilderContext(context);
var instance = startupFactory(webHostBuilderContext) ?? throw new InvalidOperationException("The specified factory returned null startup instance.");
UseStartup(instance.GetType(), context, services, instance);
Expand Down Expand Up @@ -316,12 +320,14 @@ public IWebHostBuilder Configure(Action<IApplicationBuilder> configure)
UseSetting(WebHostDefaults.ApplicationKey, startupAssemblyName);

// Clear the startup type
_startupObject = configure;
_builder.Properties[_startupConfigName] = configure;

_builder.ConfigureServices((context, services) =>
{
if (object.ReferenceEquals(_startupObject, configure))
if (_builder.Properties.TryGetValue(_startupConfigName, out var startupObject) &&
object.ReferenceEquals(startupObject, configure))
{
_builder.Properties.Remove(_startupConfigName);
services.Configure<GenericWebHostServiceOptions>(options =>
{
options.ConfigureApplication = configure;
Expand All @@ -339,12 +345,14 @@ public IWebHostBuilder Configure(Action<WebHostBuilderContext, IApplicationBuild
UseSetting(WebHostDefaults.ApplicationKey, startupAssemblyName);

// Clear the startup type
_startupObject = configure;
_builder.Properties[_startupConfigName] = configure;

_builder.ConfigureServices((context, services) =>
{
if (object.ReferenceEquals(_startupObject, configure))
if (_builder.Properties.TryGetValue(_startupConfigName, out var startupObject) &&
object.ReferenceEquals(startupObject, configure))
{
_builder.Properties.Remove(_startupConfigName);
services.Configure<GenericWebHostServiceOptions>(options =>
{
var webhostBuilderContext = GetWebHostBuilderContext(context);
Expand Down
108 changes: 106 additions & 2 deletions src/Hosting/Hosting/test/GenericWebHostBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -128,6 +130,86 @@ public void ReadsUrlsOrPorts(string urls, string httpPorts, string httpsPorts, s
Assert.Equal(expected, string.Join(';', server.Addresses));
}

[Fact]
public async Task MultipleConfigureWebHostCallsWithUseStartupLastWins()
{
var server = new TestServer();

using var host = new HostBuilder()
.ConfigureWebHost(webHostBuilder =>
{
webHostBuilder
.UseServer(server)
.UseStartup<FirstStartup>();
})
.ConfigureWebHost(webHostBuilder =>
{
webHostBuilder
.UseStartup<SecondStartup>();
})
.Build();

await host.StartAsync();
await AssertResponseContains(server.RequestDelegate, "SecondStartup");
}

[Fact]
public async Task MultipleConfigureWebHostCallsWithSameUseStartupOnlyRunsOne()
{
var server = new TestServer();

using var host = new HostBuilder()
.ConfigureWebHost(webHostBuilder =>
{
webHostBuilder
.UseServer(server)
.UseStartup<FirstStartup>();
})
.ConfigureWebHost(webHostBuilder =>
{
webHostBuilder
.UseStartup<FirstStartup>();
})
.Build();

await host.StartAsync();
Assert.Single(host.Services.GetRequiredService<IEnumerable<FirstStartup>>());
}

private async Task AssertResponseContains(RequestDelegate app, string expectedText)
{
var httpContext = new DefaultHttpContext();
httpContext.Response.Body = new MemoryStream();
await app(httpContext);
httpContext.Response.Body.Seek(0, SeekOrigin.Begin);
var bodyText = new StreamReader(httpContext.Response.Body).ReadToEnd();
Assert.Contains(expectedText, bodyText);
}

private class FirstStartup
{
public void ConfigureServices(IServiceCollection services) { services.AddSingleton<FirstStartup>(); }

public void Configure(IApplicationBuilder app)
{
Assert.NotNull(app.ApplicationServices.GetService<FirstStartup>());
Assert.Null(app.ApplicationServices.GetService<SecondStartup>());
app.Run(context => context.Response.WriteAsync("FirstStartup"));
}
}

private class SecondStartup
{
public void ConfigureServices(IServiceCollection services) { services.AddSingleton<SecondStartup>(); }

public void Configure(IApplicationBuilder app)
{
Assert.Null(app.ApplicationServices.GetService<FirstStartup>());
Assert.NotNull(app.ApplicationServices.GetService<SecondStartup>());
app.Run(context => context.Response.WriteAsync("SecondStartup"));
}
}

private class TestServer : IServer, IServerAddressesFeature
{
public TestServer()
Expand All @@ -139,9 +221,31 @@ public TestServer()

public ICollection<string> Addresses { get; } = new List<string>();
public bool PreferHostingUrls { get; set; }
public RequestDelegate RequestDelegate { get; private set; }

public Task StartAsync<TContext>(IHttpApplication<TContext> application, CancellationToken cancellationToken) => Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public void Dispose() { }

public Task StartAsync<TContext>(IHttpApplication<TContext> application, CancellationToken cancellationToken)
{
// For testing that uses RequestDelegate
RequestDelegate = async ctx =>
{
var httpContext = application.CreateContext(ctx.Features);
try
{
await application.ProcessRequestAsync(httpContext);
}
catch (Exception ex)
{
application.DisposeContext(httpContext, ex);
throw;
}
application.DisposeContext(httpContext, null);
};

return Task.CompletedTask;
}

public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
4 changes: 2 additions & 2 deletions src/Hosting/Hosting/test/WebHostBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1681,11 +1681,11 @@ public void Configure(IWebHostBuilder builder)
// This check is required because MVC still uses the
// IWebHostEnvironment instance before the container is baked
#pragma warning disable CS0618 // Type or member is obsolete
var heDescriptor = services.SingleOrDefault(s => s.ServiceType == typeof(IHostingEnvironment));
var heDescriptor = services.LastOrDefault(s => s.ServiceType == typeof(IHostingEnvironment));
Assert.NotNull(heDescriptor);
Assert.NotNull(heDescriptor.ImplementationInstance);
#pragma warning restore CS0618 // Type or member is obsolete
var wheDescriptor = services.SingleOrDefault(s => s.ServiceType == typeof(IWebHostEnvironment));
var wheDescriptor = services.LastOrDefault(s => s.ServiceType == typeof(IWebHostEnvironment));
Assert.NotNull(wheDescriptor);
Assert.NotNull(wheDescriptor.ImplementationInstance);
})
Expand Down
1 change: 1 addition & 0 deletions src/Mvc/Mvc.Testing/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<TEntryPoint>.StartServer(
Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<TEntryPoint>.UseKestrel() -> void
Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<TEntryPoint>.UseKestrel(int port) -> void
Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<TEntryPoint>.UseKestrel(System.Action<Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions!>! configureKestrelOptions) -> void
virtual Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<TEntryPoint>.CreateServer(System.IServiceProvider! serviceProvider) -> Microsoft.AspNetCore.TestHost.TestServer!
23 changes: 21 additions & 2 deletions src/Mvc/Mvc.Testing/src/WebApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ internal virtual WebApplicationFactory<TEntryPoint> WithWebHostBuilderCore(Actio
var factory = new DelegatedWebApplicationFactory(
ClientOptions,
CreateServer,
CreateServer,
CreateHost,
CreateWebHostBuilder,
CreateHostBuilder,
Expand Down Expand Up @@ -337,7 +338,10 @@ private void ConfigureHostBuilder(IHostBuilder hostBuilder)
}
else
{
webHostBuilder.UseTestServer();
webHostBuilder.ConfigureServices(services =>
{
services.AddSingleton<IServer>(CreateServer);
});
}
});
_host = CreateHost(hostBuilder);
Expand Down Expand Up @@ -565,10 +569,19 @@ private static void EnsureDepsFile()
/// <returns>The <see cref="TestServer"/> with the bootstrapped application.</returns>
protected virtual TestServer CreateServer(IWebHostBuilder builder) => new(builder);

/// <summary>
/// Creates the <see cref="TestServer"/> with the <see cref="IServiceProvider"/> from the bootstrapped application.
/// This is only called for applications using <see cref="IHostBuilder"/>. Applications based on
/// <see cref="IWebHostBuilder"/> will use <see cref="CreateHost"/> instead.
/// </summary>
/// <param name="serviceProvider">The <see cref="IServiceProvider"/> from the bootstrapped application.</param>
/// <returns></returns>
protected virtual TestServer CreateServer(IServiceProvider serviceProvider) => new(serviceProvider);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove this and consider it later if we want, as it introduces a new API.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm inclined to keep it even though introducing a new API in an obsoletion pass feels funky since we don't expose any other nice ways for users to customize TestServer with a finalized DI container and we've gotten feedback in the past about how hard it is to plugin WAF APIs into different points in the minimal host's lifecycle (mostly before and after IHostBuilder.Build, but still).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened issue #62782 for this, but I'll go ahead and merge for now so we can proceed with obsoleting WebHostBuilder.


/// <summary>
/// Creates the <see cref="IHost"/> with the bootstrapped application in <paramref name="builder"/>.
/// This is only called for applications using <see cref="IHostBuilder"/>. Applications based on
/// <see cref="IWebHostBuilder"/> will use <see cref="CreateServer"/> instead.
/// <see cref="IWebHostBuilder"/> will use <see cref="CreateServer(IWebHostBuilder)"/> instead.
/// </summary>
/// <param name="builder">The <see cref="IHostBuilder"/> used to create the host.</param>
/// <returns>The <see cref="IHost"/> with the bootstrapped application.</returns>
Expand Down Expand Up @@ -801,6 +814,7 @@ public virtual async ValueTask DisposeAsync()
private sealed class DelegatedWebApplicationFactory : WebApplicationFactory<TEntryPoint>
{
private readonly Func<IWebHostBuilder, TestServer> _createServer;
private readonly Func<IServiceProvider, TestServer> _createServerFromServiceProvider;
private readonly Func<IHostBuilder, IHost> _createHost;
private readonly Func<IWebHostBuilder?> _createWebHostBuilder;
private readonly Func<IHostBuilder?> _createHostBuilder;
Expand All @@ -810,6 +824,7 @@ private sealed class DelegatedWebApplicationFactory : WebApplicationFactory<TEnt
public DelegatedWebApplicationFactory(
WebApplicationFactoryClientOptions options,
Func<IWebHostBuilder, TestServer> createServer,
Func<IServiceProvider, TestServer> createServerFromServiceProvider,
Func<IHostBuilder, IHost> createHost,
Func<IWebHostBuilder?> createWebHostBuilder,
Func<IHostBuilder?> createHostBuilder,
Expand All @@ -819,6 +834,7 @@ public DelegatedWebApplicationFactory(
{
ClientOptions = new WebApplicationFactoryClientOptions(options);
_createServer = createServer;
_createServerFromServiceProvider = createServerFromServiceProvider;
_createHost = createHost;
_createWebHostBuilder = createWebHostBuilder;
_createHostBuilder = createHostBuilder;
Expand All @@ -829,6 +845,8 @@ public DelegatedWebApplicationFactory(

protected override TestServer CreateServer(IWebHostBuilder builder) => _createServer(builder);

protected override TestServer CreateServer(IServiceProvider serviceProvider) => _createServerFromServiceProvider(serviceProvider);

protected override IHost CreateHost(IHostBuilder builder) => _createHost(builder);

protected override IWebHostBuilder? CreateWebHostBuilder() => _createWebHostBuilder();
Expand All @@ -846,6 +864,7 @@ internal override WebApplicationFactory<TEntryPoint> WithWebHostBuilderCore(Acti
return new DelegatedWebApplicationFactory(
ClientOptions,
_createServer,
_createServerFromServiceProvider,
_createHost,
_createWebHostBuilder,
_createHostBuilder,
Expand Down
21 changes: 13 additions & 8 deletions src/Mvc/perf/benchmarkapps/BasicApi/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
Expand Down Expand Up @@ -229,25 +230,29 @@ private void DropDatabaseTables(IServiceProvider services)

public static void Main(string[] args)
{
var host = CreateWebHostBuilder(args)
using var host = CreateHost(args)
.Build();

host.Run();
}

public static IWebHostBuilder CreateWebHostBuilder(string[] args)
public static IHostBuilder CreateHost(string[] args)
{
var configuration = new ConfigurationBuilder()
.AddEnvironmentVariables()
.AddCommandLine(args)
.Build();

return new WebHostBuilder()
.UseKestrel()
.UseUrls("http://+:5000")
.UseConfiguration(configuration)
.UseContentRoot(Directory.GetCurrentDirectory())
.UseStartup<Startup>();
return new HostBuilder()
.ConfigureWebHost(webHostBuilder =>
{
webHostBuilder
.UseKestrel()
.UseUrls("http://+:5000")
.UseConfiguration(configuration)
.UseContentRoot(Directory.GetCurrentDirectory())
.UseStartup<Startup>();
});
}
}
}
Loading
Loading