diff --git a/Class/CustomScheduler.vb b/Class/CustomScheduler.vb index a78ef0f..952ce0e 100644 --- a/Class/CustomScheduler.vb +++ b/Class/CustomScheduler.vb @@ -16,17 +16,13 @@ Namespace LiteTask Private ReadOnly _tasks As New ConcurrentDictionary(Of String, ScheduledTask) Private ReadOnly _errorNotifier As ErrorNotifier Private ReadOnly _taskRunner As TaskRunner - Private disposedValue As Boolean Private ReadOnly _taskRunning As New ConcurrentDictionary(Of String, Boolean) Private ReadOnly _dependencyManager As TaskDependencyManager Private ReadOnly _taskLocks As New ConcurrentDictionary(Of String, Object) Private ReadOnly _mutexBasePath As String = "Global\LiteTask_Task_" Private ReadOnly _powerShellPathManager As PowerShellPathManager Private ReadOnly _taskStates As New ConcurrentDictionary(Of String, TaskState) - Private ReadOnly _schedulerLock As New SemaphoreSlim(1, 1) Private Const STALE_TIMEOUT_MINUTES As Integer = 15 - Private ReadOnly _processingLock As New SemaphoreSlim(1, 1) - Private _isProcessing As Boolean = False Private ReadOnly _staleTaskAlerts As New ConcurrentDictionary(Of String, DateTime) Private _lastStaleCleanupTime As DateTime = DateTime.MinValue Private Const STALE_CLEANUP_INTERVAL_SECONDS As Integer = 300 @@ -504,9 +500,12 @@ Namespace LiteTask .WindowStyle = ProcessWindowStyle.Hidden } - Dim restartProcess = Process.Start(psi) - ' Do NOT wait for or dispose the process — it must outlive this service instance - _logger.LogWarning($"[ServiceRestart] Restart helper process launched (PID: {restartProcess?.Id}). Service will restart shortly.") + ' Start the helper process, log its PID, then dispose the Process object + ' to release the managed handle. The OS process itself continues running + ' independently — Dispose only releases the .NET wrapper, not the process. + Using restartProcess = Process.Start(psi) + _logger.LogWarning($"[ServiceRestart] Restart helper process launched (PID: {restartProcess?.Id}). Service will restart shortly.") + End Using Catch ex As Exception _logger.LogError($"[ServiceRestart] Failed to initiate service restart: {ex.Message}") @@ -558,6 +557,16 @@ Namespace LiteTask _logger.LogWarning($"[DailyRestart] Initiating scheduled daily restart at {now:HH:mm:ss}") + ' Re-read notification setting from XML to pick up any changes + ' made via the GUI (which may be a separate process from the service) + Try + _xmlManager.InvalidateCache() + Dim currentSettings = _xmlManager.GetMemoryMonitorSettings() + _dailyRestartNotificationEnabled = Boolean.Parse(If(currentSettings.ContainsKey("DailyRestartNotificationEnabled"), currentSettings("DailyRestartNotificationEnabled"), "True")) + Catch settingsEx As Exception + _logger.LogWarning($"[DailyRestart] Could not re-read notification setting, using cached value: {settingsEx.Message}") + End Try + ' Send notification (only if enabled) If _dailyRestartNotificationEnabled Then Try @@ -712,9 +721,6 @@ Namespace LiteTask _taskRunning.Clear() _staleTaskAlerts.Clear() - ' Release the processing semaphores - _schedulerLock.Dispose() - _processingLock.Dispose() End If ' Clean up unmanaged resources diff --git a/Class/NotificationManager.vb b/Class/NotificationManager.vb index 8e7ed12..9e04158 100644 --- a/Class/NotificationManager.vb +++ b/Class/NotificationManager.vb @@ -16,15 +16,8 @@ Namespace LiteTask Private _processingTask As Task Private _cancellationTokenSource As New CancellationTokenSource() Private _isProcessingEmails As Boolean = False - Private disposedValue As Boolean Private ReadOnly _activeBatches As New ConcurrentDictionary(Of String, NotificationBatch) - Public Property Messages As New List(Of EmailMessage) - Public Property StartTime As DateTime - Public Property Subject As String - Public Property HighestPriority As NotificationPriority = NotificationPriority.Normal - Public Property BatchId As String - Public Sub New(logger As Logger, xmlManager As XMLManager) _logger = logger _xmlManager = xmlManager @@ -32,13 +25,6 @@ Namespace LiteTask StartEmailProcessor() End Sub - Public Sub AddMessage(message As EmailMessage) - Messages.Add(message) - If message.Priority > HighestPriority Then - HighestPriority = message.Priority - End If - End Sub - Public Sub Dispose() Implements IDisposable.Dispose Dispose(True) GC.SuppressFinalize(Me) @@ -98,39 +84,6 @@ Namespace LiteTask MyBase.Finalize() End Sub - Public Function GetCombinedBody() As String - Dim body As New StringBuilder() - - ' Add execution summary - body.AppendLine($"Execution Summary:") - body.AppendLine($"Start Time: {StartTime:yyyy-MM-dd HH:mm:ss}") - body.AppendLine($"End Time: {DateTime.Now:yyyy-MM-dd HH:mm:ss}") - body.AppendLine($"Total Messages: {Messages.Count}") - body.AppendLine() - - ' Group messages by type - Dim outputs = Messages.Where(Function(m) Not m.Body.StartsWith("Error:")).ToList() - Dim errors = Messages.Where(Function(m) m.Body.StartsWith("Error:")).ToList() - - If outputs.Count > 0 Then - body.AppendLine("Execution Output:") - For Each msg In outputs - body.AppendLine(msg.Body) - Next - body.AppendLine() - End If - - If errors.Count > 0 Then - body.AppendLine("Errors:") - For Each msg In errors - body.AppendLine(msg.Body) - Next - body.AppendLine() - End If - - Return body.ToString() - End Function - Private Sub InitializeEmailSettings() Try _emailSettings = _xmlManager.GetEmailSettings() @@ -149,31 +102,6 @@ Namespace LiteTask End Try End Sub - Private Sub ProcessEmailMessage(message As EmailMessage) - Try - Using mail As New MailMessage() - mail.From = New MailAddress(_emailSettings("EmailFrom")) - For Each recipient In _emailSettings("EmailTo").Split(";"c) - mail.To.Add(recipient.Trim()) - Next - mail.Subject = message.Subject - mail.Body = message.Body - mail.Priority = If(message.Priority = NotificationPriority.High, - MailPriority.High, MailPriority.Normal) - - _smtpClient.Send(mail) - _logger.LogInfo($"Email sent successfully: {message.Subject}") - End Using - Catch ex As Exception - _logger.LogError($"Failed to send email: {ex.Message}") - If message.RetryCount < 3 Then - message.RetryCount += 1 - _messageQueue.Enqueue(message) - Thread.Sleep(1000 * message.RetryCount) - End If - End Try - End Sub - Public Sub QueueNotification(subject As String, body As String, priority As NotificationPriority) ThrowIfDisposed() @@ -213,20 +141,25 @@ Namespace LiteTask Dim newBatch = New NotificationBatch(message) _activeBatches.TryAdd(newBatch.BatchId, newBatch) - ' Schedule batch processing (respect cancellation, observe exceptions) + ' Schedule batch processing — always remove the batch from _activeBatches + ' to prevent accumulation, but only enqueue the email if not cancelled. Task.Delay(5000, _cancellationTokenSource.Token).ContinueWith(Sub(t) - If t.IsCanceled Then Return Dim batch As NotificationBatch = Nothing If _activeBatches.TryRemove(newBatch.BatchId, batch) Then - Dim batchedMessage = New EmailMessage With { - .Subject = $"{batch.Subject} - {batch.Messages.Count} messages", + If Not t.IsCanceled Then + Dim batchedMessage = New EmailMessage With { + .Subject = If(batch.Messages.Count > 1, + $"{batch.Subject} - {batch.Messages.Count} messages", + batch.Subject), .Body = batch.GetCombinedBody(), .Priority = batch.HighestPriority, .Timestamp = batch.StartTime } - _messageQueue.Enqueue(batchedMessage) + _messageQueue.Enqueue(batchedMessage) + End If + batch.Messages.Clear() End If - End Sub, TaskContinuationOptions.OnlyOnRanToCompletion) + End Sub) End If _logger.LogInfo($"Email notification queued: {subject}") @@ -235,30 +168,6 @@ Namespace LiteTask End Try End Sub - Public Async Function SendEmailAsync(message As EmailMessage) As Task - Try - Using mail As New MailMessage() - mail.From = New MailAddress(_emailSettings("EmailFrom")) - For Each recipient In _emailSettings("EmailTo").Split(";"c) - mail.To.Add(recipient.Trim()) - Next - mail.Subject = message.Subject - mail.Body = $"{message.Body}{Environment.NewLine}{Environment.NewLine}Timestamp: {message.Timestamp}" - mail.Priority = If(message.Priority = NotificationPriority.High, - MailPriority.High, MailPriority.Normal) - - Await _smtpClient.SendMailAsync(mail) - _logger.LogInfo($"Email sent successfully: {message.Subject}") - End Using - Catch ex As Exception - _logger.LogError($"Failed to send email: {ex.Message}") - If message.RetryCount < 3 Then - message.RetryCount += 1 - _messageQueue.Enqueue(message) - End If - End Try - End Function - Private Sub ThrowIfDisposed() If _disposed Then Throw New ObjectDisposedException(GetType(NotificationManager).FullName) diff --git a/Class/XMLManager.vb b/Class/XMLManager.vb index 3894e53..56d482e 100644 --- a/Class/XMLManager.vb +++ b/Class/XMLManager.vb @@ -11,6 +11,7 @@ Namespace LiteTask Private ReadOnly _tempPath As String ' Simple cache for frequently accessed config values + Private ReadOnly _cacheLock As New Object() Private _configCache As New Dictionary(Of String, String) Private _cacheExpiry As DateTime = DateTime.MinValue Private ReadOnly _cacheTimeout As TimeSpan = TimeSpan.FromMinutes(5) @@ -553,27 +554,44 @@ Namespace LiteTask Return task End Function + ''' + ''' Invalidates the in-memory config cache so the next ReadValue call + ''' re-reads from the XML file. Useful when another process (e.g. the + ''' GUI) may have updated the file. + ''' + Public Sub InvalidateCache() + SyncLock _cacheLock + _configCache.Clear() + _cacheExpiry = DateTime.MinValue + End SyncLock + End Sub + Public Function ReadValue(section As String, key As String, defaultValue As String) As String Try Dim cacheKey = $"{section}/{key}" - - ' Check cache first - If DateTime.Now < _cacheExpiry AndAlso _configCache.ContainsKey(cacheKey) Then - Return _configCache(cacheKey) - End If - + + ' Check cache first (under lock to avoid races with InvalidateCache) + SyncLock _cacheLock + If DateTime.Now < _cacheExpiry AndAlso _configCache.ContainsKey(cacheKey) Then + Return _configCache(cacheKey) + End If + End SyncLock + Dim xmlDoc As New XmlDocument() xmlDoc.Load(_filePath) Dim node As XmlNode = xmlDoc.SelectSingleNode($"LiteTaskSettings/{section}/{key}") Dim value = If(node?.InnerText, defaultValue) - - ' Update cache - _configCache(cacheKey) = value - If _configCache.Count = 1 Then ' First item, set expiry - _cacheExpiry = DateTime.Now.Add(_cacheTimeout) - End If - + + ' Update cache – clear expired entries first to prevent unbounded growth + SyncLock _cacheLock + If DateTime.Now >= _cacheExpiry Then + _configCache.Clear() + _cacheExpiry = DateTime.Now.Add(_cacheTimeout) + End If + _configCache(cacheKey) = value + End SyncLock + Return value Catch ex As Exception _logger.LogError($"Error reading value for {section}/{key}: {ex.Message}") @@ -872,11 +890,11 @@ Namespace LiteTask keyNode.InnerText = value xmlDoc.Save(_filePath) - ' Clear cache for this key and related values - Dim cacheKey = $"{section}/{key}" - If _configCache.ContainsKey(cacheKey) Then + ' Clear cache for this key + SyncLock _cacheLock + Dim cacheKey = $"{section}/{key}" _configCache.Remove(cacheKey) - End If + End SyncLock Catch ex As Exception _logger.LogError($"Error writing value for {section}/{key}: {ex.Message}") End Try