From 5aa744d3bb8f87ce7253bd9b826502a1883e4495 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 13:24:38 +0000 Subject: [PATCH 1/4] Fix daily restart email notification ignoring unchecked checkbox The service loaded the DailyRestartNotificationEnabled setting once at startup and never re-read it. When the user unchecked the notification checkbox in the GUI (a separate process), the service still held the old cached value and sent the email anyway. Fix: re-read the notification setting directly from the XML config file (with cache invalidation) right before the notification check in CheckDailyRestart. Also add XMLManager.InvalidateCache() so the service bypasses stale in-memory cache entries written by the GUI process. https://claude.ai/code/session_015AJHHxnPSut7ki1EQEp3C9 --- Class/CustomScheduler.vb | 10 ++++++++++ Class/XMLManager.vb | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/Class/CustomScheduler.vb b/Class/CustomScheduler.vb index a78ef0f..0cf8365 100644 --- a/Class/CustomScheduler.vb +++ b/Class/CustomScheduler.vb @@ -558,6 +558,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 diff --git a/Class/XMLManager.vb b/Class/XMLManager.vb index 3894e53..0eddb02 100644 --- a/Class/XMLManager.vb +++ b/Class/XMLManager.vb @@ -553,6 +553,16 @@ 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() + _configCache.Clear() + _cacheExpiry = DateTime.MinValue + End Sub + Public Function ReadValue(section As String, key As String, defaultValue As String) As String Try Dim cacheKey = $"{section}/{key}" From cb85d407e0b277b1f50a7303b79484cedbb73b18 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 13:33:15 +0000 Subject: [PATCH 2/4] Fix config cache never clearing expired entries (memory leak) The _configCache dictionary only set its expiry when the first item was added, but never cleared old entries on expiry. Each 5-minute cycle added new entries on top of stale ones, causing unbounded growth over the service's uptime. Fix: when the cache has expired, clear all entries before adding the new value and reset the expiry timer. https://claude.ai/code/session_015AJHHxnPSut7ki1EQEp3C9 --- Class/XMLManager.vb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Class/XMLManager.vb b/Class/XMLManager.vb index 0eddb02..7d15d87 100644 --- a/Class/XMLManager.vb +++ b/Class/XMLManager.vb @@ -578,11 +578,12 @@ Namespace LiteTask 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 + ' Update cache – clear expired entries first to prevent unbounded growth + If DateTime.Now >= _cacheExpiry Then + _configCache.Clear() _cacheExpiry = DateTime.Now.Add(_cacheTimeout) End If + _configCache(cacheKey) = value Return value Catch ex As Exception From 1430618b88cd5ebefeefaa94e0c139541978ed2f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 13:41:35 +0000 Subject: [PATCH 3/4] Fix memory leaks: Process handle, notification batches, dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. CustomScheduler.InitiateServiceRestart: wrap Process.Start in Using to release the managed handle immediately. The OS process continues running independently — Dispose only frees the .NET wrapper. 2. NotificationManager: change ContinueWith from OnlyOnRanToCompletion to unconditional so _activeBatches entries are always removed, even when the cancellation token fires. Also clear batch.Messages after processing to release EmailMessage references sooner. 3. NotificationManager: remove unused Messages, StartTime, Subject, HighestPriority, BatchId properties and AddMessage/GetCombinedBody methods that duplicated NotificationBatch and were never called — the Messages list in particular grew unbounded. https://claude.ai/code/session_015AJHHxnPSut7ki1EQEp3C9 --- Class/CustomScheduler.vb | 9 ++++-- Class/NotificationManager.vb | 63 +++++++----------------------------- 2 files changed, 17 insertions(+), 55 deletions(-) diff --git a/Class/CustomScheduler.vb b/Class/CustomScheduler.vb index 0cf8365..920cd75 100644 --- a/Class/CustomScheduler.vb +++ b/Class/CustomScheduler.vb @@ -504,9 +504,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}") diff --git a/Class/NotificationManager.vb b/Class/NotificationManager.vb index 8e7ed12..60682bf 100644 --- a/Class/NotificationManager.vb +++ b/Class/NotificationManager.vb @@ -19,12 +19,6 @@ Namespace LiteTask 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 +26,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 +85,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() @@ -213,20 +167,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}") From 2016e0365a9f47f72cdeaa4e20dad5aba59659eb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 13:46:17 +0000 Subject: [PATCH 4/4] Thread-safe cache operations and remove dead code XMLManager: protect _configCache with SyncLock in InvalidateCache, ReadValue, and WriteValue to prevent races between the scheduler thread and concurrent reads. Remove dead code: - NotificationManager: unused ProcessEmailMessage (superseded by StartEmailProcessor), unused SendEmailAsync (never called), unused disposedValue field (duplicate of _disposed) - CustomScheduler: unused disposedValue, _isProcessing, _processingLock and _schedulerLock (declared/disposed but never acquired) https://claude.ai/code/session_015AJHHxnPSut7ki1EQEp3C9 --- Class/CustomScheduler.vb | 7 ----- Class/NotificationManager.vb | 50 ------------------------------------ Class/XMLManager.vb | 45 ++++++++++++++++++-------------- 3 files changed, 26 insertions(+), 76 deletions(-) diff --git a/Class/CustomScheduler.vb b/Class/CustomScheduler.vb index 920cd75..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 @@ -725,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 60682bf..9e04158 100644 --- a/Class/NotificationManager.vb +++ b/Class/NotificationManager.vb @@ -16,7 +16,6 @@ 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 Sub New(logger As Logger, xmlManager As XMLManager) @@ -103,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() @@ -194,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 7d15d87..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) @@ -559,32 +560,38 @@ Namespace LiteTask ''' GUI) may have updated the file. ''' Public Sub InvalidateCache() - _configCache.Clear() - _cacheExpiry = DateTime.MinValue + 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 – clear expired entries first to prevent unbounded growth - If DateTime.Now >= _cacheExpiry Then - _configCache.Clear() - _cacheExpiry = DateTime.Now.Add(_cacheTimeout) - End If - _configCache(cacheKey) = value - + 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}") @@ -883,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