diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/HostBuilder.netcoreapp.cs b/src/libraries/Microsoft.Extensions.Hosting/src/HostBuilder.netcoreapp.cs index 453f92886d3e7f..d7567473b454b4 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/src/HostBuilder.netcoreapp.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/src/HostBuilder.netcoreapp.cs @@ -13,7 +13,7 @@ private static void AddLifetime(IServiceCollection services) { if (!OperatingSystem.IsAndroid() && !OperatingSystem.IsBrowser() && !OperatingSystem.IsWasi() && !OperatingSystem.IsIOS() && !OperatingSystem.IsTvOS()) { - services.AddSingleton(); + HostingHostBuilderExtensions.AddConsoleLifetime(services); } else { diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/HostBuilder.notnetcoreapp.cs b/src/libraries/Microsoft.Extensions.Hosting/src/HostBuilder.notnetcoreapp.cs index 4e27703be2dcb8..f100db0029d8c3 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/src/HostBuilder.notnetcoreapp.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/src/HostBuilder.notnetcoreapp.cs @@ -10,7 +10,7 @@ public partial class HostBuilder { private static void AddLifetime(IServiceCollection services) { - services.AddSingleton(); + HostingHostBuilderExtensions.AddConsoleLifetime(services); } } } diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/HostingHostBuilderExtensions.cs b/src/libraries/Microsoft.Extensions.Hosting/src/HostingHostBuilderExtensions.cs index d23fd0d7903d74..88f4508355c1f8 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/src/HostingHostBuilderExtensions.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/src/HostingHostBuilderExtensions.cs @@ -16,6 +16,7 @@ using Microsoft.Extensions.Hosting.Internal; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.EventLog; +using Microsoft.Extensions.Options; namespace Microsoft.Extensions.Hosting { @@ -341,6 +342,26 @@ internal static ServiceProviderOptions CreateDefaultServiceProviderOptions(HostB }; } + // Factory helper for registering ConsoleLifetime. Uses the internal ctor so the diagnostic + // log message in WaitForStartAsync can inspect the application's IConfiguration to detect + // a likely-misconfigured content root. Going through a factory (rather than typed + // registration) lets us pass IConfiguration without adding a new public ctor to the + // pubternal ConsoleLifetime type. + [UnsupportedOSPlatform("android")] + [UnsupportedOSPlatform("browser")] + [UnsupportedOSPlatform("ios")] + [UnsupportedOSPlatform("tvos")] + internal static void AddConsoleLifetime(IServiceCollection collection) + { + collection.AddSingleton(static sp => new ConsoleLifetime( + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetService())); + } + /// /// Listens for Ctrl+C or SIGTERM and calls to start the shutdown process. /// This will unblock extensions like RunAsync and WaitForShutdownAsync. @@ -353,7 +374,7 @@ internal static ServiceProviderOptions CreateDefaultServiceProviderOptions(HostB [UnsupportedOSPlatform("tvos")] public static IHostBuilder UseConsoleLifetime(this IHostBuilder hostBuilder) { - return hostBuilder.ConfigureServices(collection => collection.AddSingleton()); + return hostBuilder.ConfigureServices(AddConsoleLifetime); } /// @@ -371,7 +392,7 @@ public static IHostBuilder UseConsoleLifetime(this IHostBuilder hostBuilder, Act { return hostBuilder.ConfigureServices(collection => { - collection.AddSingleton(); + AddConsoleLifetime(collection); collection.Configure(configureOptions); }); } diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.cs b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.cs index b19ae087d23db0..a706817f8229aa 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.cs @@ -2,9 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.IO; using System.Runtime.Versioning; +using System.Security; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -44,6 +48,13 @@ public ConsoleLifetime(IOptions options, IHostEnvironmen /// An object to configure the logging system and create instances of from the registered . /// or or or or is . public ConsoleLifetime(IOptions options, IHostEnvironment environment, IHostApplicationLifetime applicationLifetime, IOptions hostOptions, ILoggerFactory loggerFactory) + : this(options, environment, applicationLifetime, hostOptions, loggerFactory, configuration: null) { } + + // Internal ctor accepting IConfiguration for diagnostic logging. Kept internal to avoid + // adding a new public API surface to a pubternal class; ConsoleLifetime is registered via + // a factory (see HostingHostBuilderExtensions.AddConsoleLifetime) so DI doesn't need + // to pick this ctor automatically. + internal ConsoleLifetime(IOptions options, IHostEnvironment environment, IHostApplicationLifetime applicationLifetime, IOptions hostOptions, ILoggerFactory loggerFactory, IConfiguration? configuration) { ArgumentNullException.ThrowIfNull(options?.Value, nameof(options)); ArgumentNullException.ThrowIfNull(applicationLifetime); @@ -55,6 +66,7 @@ public ConsoleLifetime(IOptions options, IHostEnvironmen Environment = environment; ApplicationLifetime = applicationLifetime; HostOptions = hostOptions.Value; + Configuration = configuration; Logger = loggerFactory.CreateLogger("Microsoft.Hosting.Lifetime"); } @@ -66,6 +78,8 @@ public ConsoleLifetime(IOptions options, IHostEnvironmen private HostOptions HostOptions { get; } + private IConfiguration? Configuration { get; } + private ILogger Logger { get; } /// @@ -77,6 +91,16 @@ public Task WaitForStartAsync(CancellationToken cancellationToken) { if (!Options.SuppressStatusMessages) { + // Log a diagnostic when the content root is the current working directory and looks + // suspicious. Logging here (rather than in OnApplicationStarted) ensures the message + // is still emitted when the host fails to start (e.g. a hosted service can't find + // appsettings.json because the working directory is unintentionally "/" in a + // container without WORKDIR or when launched by systemd without WorkingDirectory). + if (Logger.IsEnabled(LogLevel.Information) && ShouldWarnAboutContentRoot()) + { + Logger.LogInformation("Content root path is the current working directory ({ContentRoot}). To override, set the content root explicitly.", Environment.ContentRootPath); + } + _applicationStartedRegistration = ApplicationLifetime.ApplicationStarted.Register(state => { ((ConsoleLifetime)state!).OnApplicationStarted(); @@ -95,6 +119,106 @@ public Task WaitForStartAsync(CancellationToken cancellationToken) return Task.CompletedTask; } + private bool ShouldWarnAboutContentRoot() + { + try + { + string contentRootPath = Environment.ContentRootPath; + + // Hosting does not normalize ContentRootPath, so an exact match + // indicates the working directory is being used as the content root. + // A user-specified content root that happens to expand to the same directory + // but as a different string (e.g. with a trailing separator, or via "./") + // is assumed to be intentional. + if (!string.Equals(contentRootPath, Directory.GetCurrentDirectory(), StringComparison.Ordinal)) + { + return false; + } + + // Case 1: the content root is a filesystem root (e.g. "/" or "C:\"). + // Almost certainly not where the user intended their app to be rooted - log + // regardless of how it got set. + if (string.Equals(Path.GetPathRoot(contentRootPath), contentRootPath, StringComparison.Ordinal)) + { + return true; + } + + // Case 2: at least one file-based configuration source is rooted at the content + // root but none of those files exist on disk. This typically means appsettings.json + // was expected (hosting defaults registered it) but the working directory doesn't + // actually contain it - a sign the working directory is not the intended app + // directory. + return AllContentRootFileSourcesAreMissing(contentRootPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or ArgumentException) + { + // This diagnostic is a heuristic. I/O and security failures from + // querying the current directory or probing file-based configuration providers + // should simply skip the diagnostic. + return false; + } + } + + private bool AllContentRootFileSourcesAreMissing(string contentRootPath) + { + if (Configuration is not IConfigurationRoot configRoot) + { + return false; + } + + bool sawContentRootSource = false; + + foreach (IConfigurationProvider provider in configRoot.Providers) + { + if (provider is not FileConfigurationProvider fileProvider) + { + continue; + } + + FileConfigurationSource source = fileProvider.Source; + if (source.FileProvider is not PhysicalFileProvider physicalProvider) + { + // We can only compare paths against the content root for physical providers. + continue; + } + + if (!TrimTrailingDirectorySeparator(physicalProvider.Root).Equals(contentRootPath.AsSpan(), StringComparison.Ordinal)) + { + continue; + } + + if (source.Path is not string sourcePath) + { + continue; + } + + sawContentRootSource = true; + + if (source.FileProvider.GetFileInfo(sourcePath).Exists) + { + return false; + } + } + + return sawContentRootSource; + } + + private static ReadOnlySpan TrimTrailingDirectorySeparator(string path) + { + if (path.Length <= 1) + { + return path; + } + + char last = path[path.Length - 1]; + if (last == Path.DirectorySeparatorChar || last == Path.AltDirectorySeparatorChar) + { + return path.AsSpan(0, path.Length - 1); + } + + return path; + } + private partial void RegisterShutdownHandlers(); private void OnApplicationStarted() diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/ConsoleLifetimeTests.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/ConsoleLifetimeTests.cs new file mode 100644 index 00000000000000..731a1f8850dfdd --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/ConsoleLifetimeTests.cs @@ -0,0 +1,221 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.DotNet.RemoteExecutor; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Microsoft.Extensions.Hosting.Tests +{ + public class ConsoleLifetimeTests + { + private const string ContentRootDiagnosticMessagePrefix = "Content root path is the current working directory"; + private const string ApplicationStartedMessage = "Application started. Press Ctrl+C to shut down."; + + [Fact] + public async Task LogsBasicStartupMessages() + { + string cwd = Directory.GetCurrentDirectory(); + string[] messages = await RunWithDefaultsAsync( + contentRootPath: cwd, + environmentName: "Production"); + + Assert.Contains(ApplicationStartedMessage, messages); + Assert.Contains("Hosting environment: Production", messages); + Assert.Contains($"Content root path: {cwd}", messages); + } + + [Fact] + public async Task DoesNotLogStartupMessages_WhenSuppressed() + { + string[] messages = await RunWithDefaultsAsync( + contentRootPath: Directory.GetCurrentDirectory(), + configureLifetime: o => o.SuppressStatusMessages = true); + + Assert.Empty(messages); + } + + [Fact] + public async Task DoesNotLogContentRootDiagnostic_WhenContentRootDiffersFromCwd() + { + using var differentDirectory = new TempDirectory(); + + string[] messages = await RunWithDefaultsAsync(contentRootPath: differentDirectory.Path); + + Assert.DoesNotContain(messages, m => m.StartsWith(ContentRootDiagnosticMessagePrefix)); + } + + // The tests below mutate the process-global current working directory, so they run in + // their own remote process. + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void DoesNotLogContentRootDiagnostic_WhenAppSettingsExists() + { + RemoteExecutor.Invoke(static async () => + { + using var dir = new CwdTempDirectory(); + File.WriteAllText(Path.Combine(dir.ResolvedPath, "appsettings.json"), "{}"); + + string[] messages = await RunWithDefaultsAsync(contentRootPath: dir.ResolvedPath); + + Assert.DoesNotContain(messages, m => m.StartsWith(ContentRootDiagnosticMessagePrefix)); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void LogsContentRootDiagnostic_WhenDefaultsAppliedAndAppSettingsMissing() + { + RemoteExecutor.Invoke(static async () => + { + using var dir = new CwdTempDirectory(); + Assert.False(File.Exists(Path.Combine(dir.ResolvedPath, "appsettings.json"))); + + string[] messages = await RunWithDefaultsAsync(contentRootPath: dir.ResolvedPath); + + Assert.Contains(messages, m => m.StartsWith(ContentRootDiagnosticMessagePrefix)); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void DoesNotLogContentRootDiagnostic_WhenNoFileSourceRegistered() + { + // No file-based configuration sources rooted at the content root means the user opted + // out of file-based config; absence of appsettings.json is not a signal. + RemoteExecutor.Invoke(static async () => + { + using var dir = new CwdTempDirectory(); + Assert.False(File.Exists(Path.Combine(dir.ResolvedPath, "appsettings.json"))); + + string[] messages = await RunWithoutDefaultsAsync(contentRootPath: dir.ResolvedPath); + + Assert.DoesNotContain(messages, m => m.StartsWith(ContentRootDiagnosticMessagePrefix)); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void LogsContentRootDiagnostic_WhenContentRootIsFilesystemRoot() + { + RemoteExecutor.Invoke(static async () => + { + string root = Path.GetPathRoot(Directory.GetCurrentDirectory()); + Assert.False(string.IsNullOrEmpty(root)); + Directory.SetCurrentDirectory(root); + + // Even with no file sources registered, a filesystem-root content root is + // suspicious enough to always log. + string[] messages = await RunWithoutDefaultsAsync(contentRootPath: Directory.GetCurrentDirectory()); + + Assert.Contains(messages, m => m.StartsWith(ContentRootDiagnosticMessagePrefix)); + }).Dispose(); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void LogsContentRootDiagnostic_EvenWhenApplicationFailsToStart() + { + // The diagnostic should fire when the lifetime starts waiting, before any hosted + // services run, so users still see it when the host fails during startup. + RemoteExecutor.Invoke(static async () => + { + using var dir = new CwdTempDirectory(); + + var loggerProvider = new TestLoggerProvider(); + IHostBuilder builder = new HostBuilder() + .ConfigureDefaults(Array.Empty()) + .UseContentRoot(dir.ResolvedPath) + .ConfigureLogging(logging => logging.AddProvider(loggerProvider)) + .ConfigureServices(services => services.AddHostedService()); + + using IHost host = builder.Build(); + await Assert.ThrowsAsync(() => host.StartAsync()); + + string[] messages = loggerProvider.GetEvents().Select(e => e.Message).ToArray(); + Assert.Contains(messages, m => m.StartsWith(ContentRootDiagnosticMessagePrefix)); + Assert.DoesNotContain(ApplicationStartedMessage, messages); + }).Dispose(); + } + + private static async Task RunWithDefaultsAsync( + string contentRootPath, + string environmentName = "Production", + Action configureLifetime = null) + { + var loggerProvider = new TestLoggerProvider(); + IHostBuilder builder = new HostBuilder() + .ConfigureDefaults(Array.Empty()) + .UseContentRoot(contentRootPath) + .UseEnvironment(environmentName) + .ConfigureLogging(logging => logging.AddProvider(loggerProvider)); + + if (configureLifetime is not null) + { + builder = builder.UseConsoleLifetime(configureLifetime); + } + + using IHost host = builder.Build(); + await host.StartAsync(); + await host.StopAsync(); + + return loggerProvider.GetEvents().Select(e => e.Message).ToArray(); + } + + private static async Task RunWithoutDefaultsAsync(string contentRootPath) + { + var loggerProvider = new TestLoggerProvider(); + using IHost host = new HostBuilder() + .UseContentRoot(contentRootPath) + .ConfigureLogging(logging => logging.AddProvider(loggerProvider)) + .UseConsoleLifetime() + .Build(); + + await host.StartAsync(); + await host.StopAsync(); + + return loggerProvider.GetEvents().Select(e => e.Message).ToArray(); + } + + // TempDirectory that also sets the current working directory to its path for the lifetime + // of the instance. Always used inside RemoteExecutor.Invoke to keep the CWD change + // isolated from the test runner process. + // + // On macOS, Path.GetTempPath() returns a path under /tmp while Directory.GetCurrentDirectory() + // returns the resolved path (under /private/tmp). Capture the resolved CWD so callers can use + // a path that matches what ConsoleLifetime sees at runtime. + private sealed class CwdTempDirectory : TempDirectory + { + private readonly string _previousCwd; + + /// The resolved current directory after this instance's SetCurrentDirectory call. + public string ResolvedPath { get; } + + public CwdTempDirectory() + { + _previousCwd = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(Path); + ResolvedPath = Directory.GetCurrentDirectory(); + } + + protected override void DeleteDirectory() + { + // Restore the CWD first - on Windows, a directory can't be deleted while it is + // the process's current working directory. + try { Directory.SetCurrentDirectory(_previousCwd); } + catch { } + base.DeleteDirectory(); + } + } + + private sealed class ThrowingHostedService : IHostedService + { + public Task StartAsync(CancellationToken cancellationToken) => + throw new InvalidOperationException("startup failed"); + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/Internal/HostTests.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/Internal/HostTests.cs index c345548c3a44c3..f43ffa4edc4844 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/Internal/HostTests.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/Internal/HostTests.cs @@ -1453,9 +1453,8 @@ public async Task HostedServiceFactoryExceptionGetsLogged() await Assert.ThrowsAsync(() => host.StartAsync()); LogEvent[] events = logger.GetEvents(); - Assert.Single(events); - Assert.Equal(LogLevel.Error, events[0].LogLevel); - Assert.Equal("HostedServiceStartupFaulted", events[0].EventId.Name); + LogEvent errorEvent = Assert.Single(events, e => e.LogLevel == LogLevel.Error); + Assert.Equal("HostedServiceStartupFaulted", errorEvent.EventId.Name); } [Fact]