src/WebJobs.Script.WebHost/FileMonitoringService.cs (347 lines of code) (raw):

// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Globalization; using System.IO; using System.Linq; using System.Reactive.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Azure.WebJobs.Logging; using Microsoft.Azure.WebJobs.Script.Eventing; using Microsoft.Azure.WebJobs.Script.Eventing.File; using Microsoft.Azure.WebJobs.Script.IO; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using IApplicationLifetime = Microsoft.AspNetCore.Hosting.IApplicationLifetime; namespace Microsoft.Azure.WebJobs.Script.WebHost { public class FileMonitoringService : IFileMonitoringService, IDisposable { private readonly ScriptJobHostOptions _scriptOptions; private readonly IScriptEventManager _eventManager; private readonly IApplicationLifetime _applicationLifetime; private readonly IScriptHostManager _scriptHostManager; private readonly IEnvironment _environment; private readonly string _hostLogPath; private readonly ILogger _logger; private readonly ILogger<FileMonitoringService> _typedLogger; private readonly IList<IDisposable> _eventSubscriptions = new List<IDisposable>(); private readonly Func<Task> _restart; private readonly Action _shutdown; private readonly ImmutableArray<string> _rootDirectorySnapshot; private AutoRecoveringFileSystemWatcher _debugModeFileWatcher; private AutoRecoveringFileSystemWatcher _diagnosticModeFileWatcher; private FileWatcherEventSource _fileEventSource; private bool _restartScheduled; private bool _shutdownScheduled; private long _restartRequested; private bool _disposed = false; private bool _watchersStopped = false; private object _stopWatchersLock = new object(); private long _suspensionRequestsCount = 0; public FileMonitoringService(IOptions<ScriptJobHostOptions> scriptOptions, ILoggerFactory loggerFactory, IScriptEventManager eventManager, IApplicationLifetime applicationLifetime, IScriptHostManager scriptHostManager, IEnvironment environment) { _scriptOptions = scriptOptions.Value; _eventManager = eventManager; _applicationLifetime = applicationLifetime; _scriptHostManager = scriptHostManager; _hostLogPath = Path.Combine(_scriptOptions.RootLogPath, "Host"); _logger = loggerFactory.CreateLogger(LogCategories.Startup); _environment = environment; // Use this for newer logs as we can't change existing categories of log messages _typedLogger = loggerFactory.CreateLogger<FileMonitoringService>(); // If a file change should result in a restart, we debounce the event to // ensure that only a single restart is triggered within a specific time window. // This allows us to deal with a large set of file change events that might // result from a bulk copy/unzip operation. In such cases, we only want to // restart after ALL the operations are complete and there is a quiet period. _restart = RestartAsync; _restart = _restart.Debounce(500); _shutdown = Shutdown; _shutdown = _shutdown.Debounce(milliseconds: 500); _rootDirectorySnapshot = GetDirectorySnapshot(); } internal ImmutableArray<string> GetDirectorySnapshot() { if (_scriptOptions.RootScriptPath != null) { try { return Directory.EnumerateDirectories(_scriptOptions.RootScriptPath).ToImmutableArray(); } catch (DirectoryNotFoundException) { _logger.LogInformation($"Unable to get directory snapshot. No directory present at {_scriptOptions.RootScriptPath}"); } } return ImmutableArray<string>.Empty; } public Task StartAsync(CancellationToken cancellationToken) { InitializeFileWatchers(); return Task.CompletedTask; } public Task StopAsync(CancellationToken cancellationToken) { StopFileWatchers(); return Task.CompletedTask; } public IDisposable SuspendRestart(bool autoRestart) { return new SuspendRestartRequest(this, autoRestart); } private void ResumeRestartIfScheduled() { if (_restartScheduled) { using (System.Threading.ExecutionContext.SuppressFlow()) { _typedLogger.LogDebug("Resuming scheduled restart."); Task.Run(async () => await ScheduleRestartAsync()); } } } private async Task ScheduleRestartAsync(bool shutdown) { _restartScheduled = true; if (shutdown) { _shutdownScheduled = true; } await ScheduleRestartAsync(); } private async Task ScheduleRestartAsync() { if (Interlocked.Read(ref _suspensionRequestsCount) > 0) { _logger.LogDebug("Restart requested while currently suspended. Ignoring request."); } else { if (_shutdownScheduled) { _shutdown(); } else { await _restart(); } } } /// <summary> /// Initialize file and directory change monitoring. /// </summary> private void InitializeFileWatchers() { if (_scriptOptions.FileWatchingEnabled) { _fileEventSource = new FileWatcherEventSource(_eventManager, EventSources.ScriptFiles, _scriptOptions.RootScriptPath); _eventSubscriptions.Add(_eventManager.OfType<FileEvent>() .Where(f => string.Equals(f.Source, EventSources.ScriptFiles, StringComparison.Ordinal)) .Subscribe(e => OnFileChanged(e.FileChangeArguments))); _logger.LogDebug("File event source initialized."); } _eventSubscriptions.Add(_eventManager.OfType<HostRestartEvent>() .Subscribe((msg) => ScheduleRestartAsync(false) .ContinueWith(t => _logger.LogCritical(t.Exception.Message), TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously))); // Delay starting up for logging and debug file watchers to avoid long start up times Utility.ExecuteAfterColdStartDelay(_environment, InitializeSecondaryFileWatchers); } /// <summary> /// Initializes the file and directory monitoring that does not need to happen as part of a Host startup /// These watchers can be started after a delay to avoid startup performance issue /// </summary> private void InitializeSecondaryFileWatchers() { if (_watchersStopped) { return; } lock (_stopWatchersLock) { if (!_watchersStopped) { FileUtility.EnsureDirectoryExists(_hostLogPath); _debugModeFileWatcher = new AutoRecoveringFileSystemWatcher(_hostLogPath, ScriptConstants.DebugSentinelFileName, includeSubdirectories: false, changeTypes: WatcherChangeTypes.Created | WatcherChangeTypes.Changed); _debugModeFileWatcher.Changed += OnDebugModeFileChanged; _logger.LogDebug("Debug file watch initialized."); _diagnosticModeFileWatcher = new AutoRecoveringFileSystemWatcher(_hostLogPath, ScriptConstants.DiagnosticSentinelFileName, includeSubdirectories: false, changeTypes: WatcherChangeTypes.Created | WatcherChangeTypes.Changed); _diagnosticModeFileWatcher.Changed += OnDiagnosticModeFileChanged; _logger.LogDebug("Diagnostic file watch initialized."); } } } private void StopFileWatchers() { if (_watchersStopped) { return; } lock (_stopWatchersLock) { if (_watchersStopped) { return; } _typedLogger.LogDebug("Stopping file watchers."); _fileEventSource?.Dispose(); if (_debugModeFileWatcher != null) { _debugModeFileWatcher.Changed -= OnDebugModeFileChanged; _debugModeFileWatcher.Dispose(); } if (_diagnosticModeFileWatcher != null) { _diagnosticModeFileWatcher.Changed -= OnDiagnosticModeFileChanged; _diagnosticModeFileWatcher.Dispose(); } foreach (var subscription in _eventSubscriptions) { subscription.Dispose(); } _watchersStopped = true; } } /// <summary> /// Whenever the debug sentinel file changes we update our debug timeout /// </summary> private void OnDebugModeFileChanged(object sender, FileSystemEventArgs e) { if (!_disposed) { _eventManager.Publish(new DebugNotification(nameof(FileMonitoringService), DateTime.UtcNow)); } } /// <summary> /// Whenever the diagnostic sentinel file changes we update our debug timeout /// </summary> private void OnDiagnosticModeFileChanged(object sender, FileSystemEventArgs e) { if (!_disposed) { _eventManager.Publish(new DiagnosticNotification(nameof(FileMonitoringService), DateTime.UtcNow)); } } private void OnFileChanged(FileSystemEventArgs e) { // We will perform a host restart in the following cases: // - the file change was under one of the configured watched directories (e.g. node_modules, shared code directories, etc.) // - the host.json file was changed // - a function.json file was changed // - a proxies.json file was changed // - a function directory was added/removed/renamed // A full host shutdown is performed when an assembly (.dll, .exe) in a watched directory is modified string changeDescription = string.Empty; string directory = GetRelativeDirectory(e.FullPath, _scriptOptions.RootScriptPath); string fileName = Path.GetFileName(e.Name); bool shutdown = false; if (_scriptOptions.WatchDirectories.Contains(directory)) { changeDescription = "Watched directory"; } else if (string.Equals(fileName, ScriptConstants.AppOfflineFileName, StringComparison.OrdinalIgnoreCase)) { // app_offline.htm has changed // when app_offline.htm is created, we trigger // a shutdown right away so when the host // starts back up it will be offline // when app_offline.htm is deleted, we trigger // a restart to bring the host back online changeDescription = "File"; if (File.Exists(e.FullPath)) { TraceFileChangeRestart(changeDescription, e.ChangeType.ToString(), e.FullPath, isShutdown: true); Shutdown(); } } else if (_scriptOptions.WatchFiles.Any(f => string.Equals(fileName, f, StringComparison.OrdinalIgnoreCase))) { changeDescription = "File"; } else if ((e.ChangeType == WatcherChangeTypes.Deleted || Directory.Exists(e.FullPath)) && !_rootDirectorySnapshot.SequenceEqual(Directory.EnumerateDirectories(_scriptOptions.RootScriptPath))) { // Check directory snapshot only if "Deleted" change or if directory changed changeDescription = "Directory"; } if (!string.IsNullOrEmpty(changeDescription)) { string fileExtension = Path.GetExtension(fileName); if (!string.IsNullOrEmpty(fileExtension) && ScriptConstants.AssemblyFileTypes.Contains(fileExtension, StringComparer.OrdinalIgnoreCase)) { shutdown = true; } TraceFileChangeRestart(changeDescription, e.ChangeType.ToString(), e.FullPath, shutdown); ScheduleRestartAsync(shutdown).ContinueWith(t => _logger.LogError(t.Exception, $"Error restarting host (full shutdown: {shutdown})"), TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.OnlyOnFaulted); } } private void TraceFileChangeRestart(string changeDescription, string changeType, string path, bool isShutdown) { string fileChangeMsg = string.Format(CultureInfo.InvariantCulture, "{0} change of type '{1}' detected for '{2}'", changeDescription, changeType, path); _logger.LogInformation(fileChangeMsg); string action = isShutdown ? "shutdown" : "restart"; string signalMessage = $"Host configuration has changed. Signaling {action}"; _logger.LogInformation(signalMessage); } internal static string GetRelativeDirectory(string path, string scriptRoot) { if (path.StartsWith(scriptRoot)) { string directory = path.Substring(scriptRoot.Length).TrimStart(Path.DirectorySeparatorChar); int idx = directory.IndexOf(Path.DirectorySeparatorChar); if (idx != -1) { directory = directory.Substring(0, idx); } return directory; } return string.Empty; } private Task RestartAsync() { if (!_shutdownScheduled && Interlocked.Exchange(ref _restartRequested, 1) == 0) { return _scriptHostManager.RestartHostAsync(); } return Task.CompletedTask; } private void Shutdown() { _applicationLifetime.StopApplication(); } protected virtual void Dispose(bool disposing) { if (!_disposed) { if (disposing) { StopFileWatchers(); } _disposed = true; } } public void Dispose() { Dispose(true); } internal static async Task SetAppOfflineState(string rootPath, bool offline) { string path = Path.Combine(rootPath, ScriptConstants.AppOfflineFileName); bool offlineFileExists = File.Exists(path); if (offline && !offlineFileExists) { // create the app_offline.htm file in the root script directory string content = FileUtility.ReadResourceString($"{ScriptConstants.ResourcePath}.{ScriptConstants.AppOfflineFileName}"); await FileUtility.WriteAsync(path, content); } else if (!offline && offlineFileExists) { // delete the app_offline.htm file await Utility.InvokeWithRetriesAsync(() => { if (File.Exists(path)) { File.Delete(path); } }, maxRetries: 3, retryInterval: TimeSpan.FromSeconds(1)); } } private class SuspendRestartRequest : IDisposable { private FileMonitoringService _fileMonitoringService; private bool _autoResume; private bool _disposed = false; public SuspendRestartRequest(FileMonitoringService fileMonitoringService, bool autoResume) { _fileMonitoringService = fileMonitoringService; _autoResume = autoResume; Interlocked.Increment(ref _fileMonitoringService._suspensionRequestsCount); _fileMonitoringService._typedLogger.LogDebug($"Entering restart suspension scope. ({_fileMonitoringService._suspensionRequestsCount} requests)."); } public void Dispose() { if (!_disposed) { Interlocked.Decrement(ref _fileMonitoringService._suspensionRequestsCount); _fileMonitoringService._typedLogger.LogDebug($"Exiting restart suspension scope. ({_fileMonitoringService._suspensionRequestsCount} requests)."); if (_autoResume) { _fileMonitoringService.ResumeRestartIfScheduled(); } _disposed = true; } } } } }