Skip to content

Commit ff272b8

Browse files
committed
Log a message when the content root directory is likely wrong
1 parent 8752339 commit ff272b8

6 files changed

Lines changed: 364 additions & 7 deletions

File tree

src/libraries/Microsoft.Extensions.Hosting/src/HostBuilder.netcoreapp.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ private static void AddLifetime(IServiceCollection services)
1313
{
1414
if (!OperatingSystem.IsAndroid() && !OperatingSystem.IsBrowser() && !OperatingSystem.IsWasi() && !OperatingSystem.IsIOS() && !OperatingSystem.IsTvOS())
1515
{
16-
services.AddSingleton<IHostLifetime, ConsoleLifetime>();
16+
HostingHostBuilderExtensions.AddConsoleLifetime(services);
1717
}
1818
else
1919
{

src/libraries/Microsoft.Extensions.Hosting/src/HostBuilder.notnetcoreapp.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ public partial class HostBuilder
1010
{
1111
private static void AddLifetime(IServiceCollection services)
1212
{
13-
services.AddSingleton<IHostLifetime, ConsoleLifetime>();
13+
HostingHostBuilderExtensions.AddConsoleLifetime(services);
1414
}
1515
}
1616
}

src/libraries/Microsoft.Extensions.Hosting/src/HostingHostBuilderExtensions.cs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
using Microsoft.Extensions.Hosting.Internal;
1717
using Microsoft.Extensions.Logging;
1818
using Microsoft.Extensions.Logging.EventLog;
19+
using Microsoft.Extensions.Options;
1920

2021
namespace Microsoft.Extensions.Hosting
2122
{
@@ -341,6 +342,26 @@ internal static ServiceProviderOptions CreateDefaultServiceProviderOptions(HostB
341342
};
342343
}
343344

345+
// Factory helper for registering ConsoleLifetime. Uses the internal ctor so the diagnostic
346+
// log message in WaitForStartAsync can inspect the application's IConfiguration to detect
347+
// a likely-misconfigured content root. Going through a factory (rather than typed
348+
// registration) lets us pass IConfiguration without adding a new public ctor to the
349+
// pubternal ConsoleLifetime type.
350+
[UnsupportedOSPlatform("android")]
351+
[UnsupportedOSPlatform("browser")]
352+
[UnsupportedOSPlatform("ios")]
353+
[UnsupportedOSPlatform("tvos")]
354+
internal static void AddConsoleLifetime(IServiceCollection collection)
355+
{
356+
collection.AddSingleton<IHostLifetime>(static sp => new ConsoleLifetime(
357+
sp.GetRequiredService<IOptions<ConsoleLifetimeOptions>>(),
358+
sp.GetRequiredService<IHostEnvironment>(),
359+
sp.GetRequiredService<IHostApplicationLifetime>(),
360+
sp.GetRequiredService<IOptions<HostOptions>>(),
361+
sp.GetRequiredService<ILoggerFactory>(),
362+
sp.GetService<IConfiguration>()));
363+
}
364+
344365
/// <summary>
345366
/// Listens for Ctrl+C or SIGTERM and calls <see cref="IHostApplicationLifetime.StopApplication"/> to start the shutdown process.
346367
/// This will unblock extensions like RunAsync and WaitForShutdownAsync.
@@ -353,7 +374,7 @@ internal static ServiceProviderOptions CreateDefaultServiceProviderOptions(HostB
353374
[UnsupportedOSPlatform("tvos")]
354375
public static IHostBuilder UseConsoleLifetime(this IHostBuilder hostBuilder)
355376
{
356-
return hostBuilder.ConfigureServices(collection => collection.AddSingleton<IHostLifetime, ConsoleLifetime>());
377+
return hostBuilder.ConfigureServices(AddConsoleLifetime);
357378
}
358379

359380
/// <summary>
@@ -371,7 +392,7 @@ public static IHostBuilder UseConsoleLifetime(this IHostBuilder hostBuilder, Act
371392
{
372393
return hostBuilder.ConfigureServices(collection =>
373394
{
374-
collection.AddSingleton<IHostLifetime, ConsoleLifetime>();
395+
AddConsoleLifetime(collection);
375396
collection.Configure(configureOptions);
376397
});
377398
}

src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.cs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System;
5+
using System.IO;
56
using System.Runtime.Versioning;
7+
using System.Security;
68
using System.Threading;
79
using System.Threading.Tasks;
10+
using Microsoft.Extensions.Configuration;
11+
using Microsoft.Extensions.FileProviders;
812
using Microsoft.Extensions.Logging;
913
using Microsoft.Extensions.Logging.Abstractions;
1014
using Microsoft.Extensions.Options;
@@ -44,6 +48,13 @@ public ConsoleLifetime(IOptions<ConsoleLifetimeOptions> options, IHostEnvironmen
4448
/// <param name="loggerFactory">An object to configure the logging system and create instances of <see cref="ILogger"/> from the registered <see cref="ILoggerProvider"/>.</param>
4549
/// <exception cref="ArgumentNullException"><paramref name="options"/> or <paramref name="environment"/> or <paramref name="applicationLifetime"/> or <paramref name="hostOptions"/> or <paramref name="loggerFactory"/> is <see langword="null"/>.</exception>
4650
public ConsoleLifetime(IOptions<ConsoleLifetimeOptions> options, IHostEnvironment environment, IHostApplicationLifetime applicationLifetime, IOptions<HostOptions> hostOptions, ILoggerFactory loggerFactory)
51+
: this(options, environment, applicationLifetime, hostOptions, loggerFactory, configuration: null) { }
52+
53+
// Internal ctor accepting IConfiguration for diagnostic logging. Kept internal to avoid
54+
// adding a new public API surface to a pubternal class; ConsoleLifetime is registered via
55+
// a factory (see HostingHostBuilderExtensions.AddConsoleLifetime) so DI doesn't need
56+
// to pick this ctor automatically.
57+
internal ConsoleLifetime(IOptions<ConsoleLifetimeOptions> options, IHostEnvironment environment, IHostApplicationLifetime applicationLifetime, IOptions<HostOptions> hostOptions, ILoggerFactory loggerFactory, IConfiguration? configuration)
4758
{
4859
ArgumentNullException.ThrowIfNull(options?.Value, nameof(options));
4960
ArgumentNullException.ThrowIfNull(applicationLifetime);
@@ -55,6 +66,7 @@ public ConsoleLifetime(IOptions<ConsoleLifetimeOptions> options, IHostEnvironmen
5566
Environment = environment;
5667
ApplicationLifetime = applicationLifetime;
5768
HostOptions = hostOptions.Value;
69+
Configuration = configuration;
5870
Logger = loggerFactory.CreateLogger("Microsoft.Hosting.Lifetime");
5971
}
6072

@@ -66,6 +78,8 @@ public ConsoleLifetime(IOptions<ConsoleLifetimeOptions> options, IHostEnvironmen
6678

6779
private HostOptions HostOptions { get; }
6880

81+
private IConfiguration? Configuration { get; }
82+
6983
private ILogger Logger { get; }
7084

7185
/// <summary>
@@ -77,6 +91,16 @@ public Task WaitForStartAsync(CancellationToken cancellationToken)
7791
{
7892
if (!Options.SuppressStatusMessages)
7993
{
94+
// Log a diagnostic when the content root is the current working directory and looks
95+
// suspicious. Logging here (rather than in OnApplicationStarted) ensures the message
96+
// is still emitted when the host fails to start (e.g. a hosted service can't find
97+
// appsettings.json because the working directory is unintentionally "/" in a
98+
// container without WORKDIR or when launched by systemd without WorkingDirectory).
99+
if (Logger.IsEnabled(LogLevel.Information) && ShouldWarnAboutContentRoot())
100+
{
101+
Logger.LogInformation("Content root path is the current working directory ({ContentRoot}). To override, set the content root explicitly.", Environment.ContentRootPath);
102+
}
103+
80104
_applicationStartedRegistration = ApplicationLifetime.ApplicationStarted.Register(state =>
81105
{
82106
((ConsoleLifetime)state!).OnApplicationStarted();
@@ -95,6 +119,106 @@ public Task WaitForStartAsync(CancellationToken cancellationToken)
95119
return Task.CompletedTask;
96120
}
97121

122+
private bool ShouldWarnAboutContentRoot()
123+
{
124+
try
125+
{
126+
string contentRootPath = Environment.ContentRootPath;
127+
128+
// Hosting does not normalize ContentRootPath, so an exact match
129+
// indicates the working directory is being used as the content root.
130+
// A user-specified content root that happens to expand to the same directory
131+
// but as a different string (e.g. with a trailing separator, or via "./")
132+
// is assumed to be intentional.
133+
if (!string.Equals(contentRootPath, Directory.GetCurrentDirectory(), StringComparison.Ordinal))
134+
{
135+
return false;
136+
}
137+
138+
// Case 1: the content root is a filesystem root (e.g. "/" or "C:\").
139+
// Almost certainly not where the user intended their app to be rooted - log
140+
// regardless of how it got set.
141+
if (string.Equals(Path.GetPathRoot(contentRootPath), contentRootPath, StringComparison.Ordinal))
142+
{
143+
return true;
144+
}
145+
146+
// Case 2: at least one file-based configuration source is rooted at the content
147+
// root but none of those files exist on disk. This typically means appsettings.json
148+
// was expected (hosting defaults registered it) but the working directory doesn't
149+
// actually contain it - a sign the working directory is not the intended app
150+
// directory.
151+
return AllContentRootFileSourcesAreMissing(contentRootPath);
152+
}
153+
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException)
154+
{
155+
// This diagnostic is a heuristic. I/O and security failures from
156+
// querying the current directory or probing file-based configuration providers
157+
// should simply skip the diagnostic.
158+
return false;
159+
}
160+
}
161+
162+
private bool AllContentRootFileSourcesAreMissing(string contentRootPath)
163+
{
164+
if (Configuration is not IConfigurationRoot configRoot)
165+
{
166+
return false;
167+
}
168+
169+
bool sawContentRootSource = false;
170+
171+
foreach (IConfigurationProvider provider in configRoot.Providers)
172+
{
173+
if (provider is not FileConfigurationProvider fileProvider)
174+
{
175+
continue;
176+
}
177+
178+
FileConfigurationSource source = fileProvider.Source;
179+
if (source.FileProvider is not PhysicalFileProvider physicalProvider)
180+
{
181+
// We can only compare paths against the content root for physical providers.
182+
continue;
183+
}
184+
185+
if (!TrimTrailingDirectorySeparator(physicalProvider.Root).Equals(contentRootPath.AsSpan(), StringComparison.Ordinal))
186+
{
187+
continue;
188+
}
189+
190+
if (source.Path is not string sourcePath)
191+
{
192+
continue;
193+
}
194+
195+
sawContentRootSource = true;
196+
197+
if (source.FileProvider.GetFileInfo(sourcePath).Exists)
198+
{
199+
return false;
200+
}
201+
}
202+
203+
return sawContentRootSource;
204+
}
205+
206+
private static ReadOnlySpan<char> TrimTrailingDirectorySeparator(string path)
207+
{
208+
if (path.Length <= 1)
209+
{
210+
return path;
211+
}
212+
213+
char last = path[path.Length - 1];
214+
if (last == Path.DirectorySeparatorChar || last == Path.AltDirectorySeparatorChar)
215+
{
216+
return path.AsSpan(0, path.Length - 1);
217+
}
218+
219+
return path;
220+
}
221+
98222
private partial void RegisterShutdownHandlers();
99223

100224
private void OnApplicationStarted()

0 commit comments

Comments
 (0)