diff --git a/CHANGELOG.md b/CHANGELOG.md index 3843fd2..2681968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project are documented in this file. ## [Unreleased] ### Fixed +- Task deletion not persisting when `LiteTaskService` is running: the service process keeps its own in-memory `_tasks` dictionary and re-writes the full set to `settings.xml` at the end of every execution (via `SaveTasks`). Because the GUI mutated XML directly without telling the service, the service's stale copy resurrected just-deleted tasks (and missed adds/edits) until the next service restart. The GUI now sends `LiteTaskService.CMD_RELOAD_TASKS` (custom service control 128) via `ServiceController.ExecuteCommand` after every task mutation (delete, create, edit, toggle-enable, import), which routes through `LiteTaskService.OnCustomCommand` → `CustomScheduler.ReloadTasks`. `ReloadTasks` evicts entries no longer present in XML (skipping anything currently executing) and refreshes the rest. The notification is best-effort and logs a warning when the GUI user lacks `SERVICE_USER_DEFINED_CONTROL` on the service. - Task deletion not persisting: `XMLManager.DeleteTask` only searched the canonical `LiteTaskSettings/Tasks/Task` path while `GetAllTaskNames`/`LoadTask` accept three layouts, so non-canonical entries survived deletion. `CustomScheduler.SaveTasks` then re-merged the surviving XML entry back into `_tasks` via `GetAllTasks`, resurrecting the task. `DeleteTask` now removes every matching node across supported layouts (under the existing write lock, with atomic save) and `SaveTasks` treats the in-memory `_tasks` dictionary as the source of truth. ### Changed diff --git a/Class/CustomScheduler.vb b/Class/CustomScheduler.vb index 56a17ee..d5979c0 100644 --- a/Class/CustomScheduler.vb +++ b/Class/CustomScheduler.vb @@ -1113,6 +1113,63 @@ Namespace LiteTask End Try End Sub + ''' + ''' Hot-reload entry point invoked by LiteTaskService.OnCustomCommand + ''' after the GUI mutates settings.xml. Evicts in-memory entries for + ''' tasks that no longer exist on disk (skipping anything currently + ''' executing) and refreshes the rest from XML, so the service stops + ''' resurrecting deleted tasks via SaveTasks at the next execution. + ''' + Public Sub ReloadTasks() + Try + _logger.LogInfo("Reloading tasks from XML (hot reload)") + + Dim newTaskNames = _xmlManager.GetAllTaskNames() + Dim newNameSet = New HashSet(Of String)(newTaskNames, StringComparer.Ordinal) + + Dim removed = 0 + Dim deferred = 0 + For Each existingName In _tasks.Keys.ToList() + If newNameSet.Contains(existingName) Then Continue For + + Dim isRunning As Boolean = False + _taskRunning.TryGetValue(existingName, isRunning) + If isRunning Then + _logger.LogWarning($"Hot reload: task '{existingName}' is running; eviction deferred to next reload") + deferred += 1 + Continue For + End If + + _tasks.TryRemove(existingName, Nothing) + _taskStates.TryRemove(existingName, Nothing) + _staleTaskAlerts.TryRemove(existingName, Nothing) + _activeMutexes.TryRemove(existingName, Nothing) + + Dim removedLock As Object = Nothing + If _taskLocks.TryRemove(existingName, removedLock) Then + If TypeOf removedLock Is SemaphoreSlim Then + DirectCast(removedLock, SemaphoreSlim).Dispose() + End If + End If + removed += 1 + Next + + Dim refreshed = 0 + For Each taskName In newTaskNames + Dim task = _xmlManager.LoadTask(taskName) + If task IsNot Nothing Then + _tasks(taskName) = task + refreshed += 1 + End If + Next + + _logger.LogInfo($"Hot reload complete: {refreshed} task(s) in memory, {removed} evicted, {deferred} deferred") + Catch ex As Exception + _logger.LogError($"Error during hot reload: {ex.Message}") + _logger.LogError($"StackTrace: {ex.StackTrace}") + End Try + End Sub + Private Sub HandleTaskError(task As ScheduledTask, ex As Exception) _logger.LogError($"Error executing task {task.Name}: {ex.Message}") _logger.LogError($"Stack trace: {ex.StackTrace}") diff --git a/Class/LiteTaskService.vb b/Class/LiteTaskService.vb index 110b20c..9f4fda0 100644 --- a/Class/LiteTaskService.vb +++ b/Class/LiteTaskService.vb @@ -6,6 +6,12 @@ Imports System.Timers Namespace LiteTask Public Class LiteTaskService Inherits ServiceBase + + ' Custom service control code (Windows reserves 128-255 for user-defined + ' commands) that the GUI sends after mutating settings.xml so the service + ' rereads its in-memory task list without a stop/start cycle. + Public Const CMD_RELOAD_TASKS As Integer = 128 + Private _customScheduler As CustomScheduler Private _credentialManager As CredentialManager Private _xmlManager As XMLManager @@ -266,6 +272,20 @@ Namespace LiteTask End Try End Sub + Protected Overrides Sub OnCustomCommand(command As Integer) + Try + Select Case command + Case CMD_RELOAD_TASKS + _logger.LogInfo("Received CMD_RELOAD_TASKS from GUI; reloading tasks from settings.xml") + _customScheduler.ReloadTasks() + Case Else + MyBase.OnCustomCommand(command) + End Select + Catch ex As Exception + _logger.LogError($"Error handling custom service command {command}: {ex.Message}") + End Try + End Sub + Private Function IsServiceInstalled() As Boolean Try Using sc = New ServiceController("LiteTaskService") diff --git a/Forms/MainForm.vb b/Forms/MainForm.vb index acafc5b..7420a57 100644 --- a/Forms/MainForm.vb +++ b/Forms/MainForm.vb @@ -293,6 +293,7 @@ Namespace LiteTask ApplicationContainer.GetService(Of Logger)()) If taskForm.ShowDialog() = DialogResult.OK Then + NotifyServiceTasksChanged() RefreshTaskList() _logger.LogInfo("Task created successfully") End If @@ -356,6 +357,8 @@ Namespace LiteTask "Save Error", MessageBoxButtons.OK, MessageBoxIcon.Warning) End Try + NotifyServiceTasksChanged() + Await RefreshUIAsync() If errorCount = 0 AndAlso tasksToDelete.Count > 0 Then @@ -438,6 +441,7 @@ Namespace LiteTask Using taskForm As New TaskForm(_credentialManager, _customScheduler, _logger, task) If taskForm.ShowDialog() = DialogResult.OK Then + NotifyServiceTasksChanged() RefreshTaskList() _logger.LogInfo($"Task {taskName} edited successfully") End If @@ -642,6 +646,7 @@ Namespace LiteTask End If Next + NotifyServiceTasksChanged() RefreshTaskList() Catch ex As Exception _logger.LogError($"Error toggling task enabled state: {ex.Message}") @@ -770,6 +775,8 @@ Namespace LiteTask _logger.LogInfo($"Imported task: {taskName}") Next + NotifyServiceTasksChanged() + MessageBox.Show($"Successfully imported {importedTaskNames.Count} tasks.", "Import Successful", MessageBoxButtons.OK, MessageBoxIcon.Information) _logger.LogInfo($"Imported {importedTaskNames.Count} tasks successfully") Catch ex As Exception @@ -2099,6 +2106,33 @@ Namespace LiteTask Return _isServiceRunning End Function + ''' + ''' Signals the running LiteTaskService (separate process) to reload its + ''' in-memory task list from settings.xml. Without this, the service keeps + ''' a stale _tasks dictionary and resurrects deleted tasks via SaveTasks + ''' at the end of the next execution. Best-effort: a non-admin GUI user + ''' may lack SERVICE_USER_DEFINED_CONTROL on the service, in which case + ''' the call is logged as a warning and the service stays stale until it + ''' is restarted. + ''' + Private Sub NotifyServiceTasksChanged() + Try + Using sc = New ServiceController(SERVICE_NAME) + If sc.Status <> ServiceControllerStatus.Running Then Return + sc.ExecuteCommand(LiteTaskService.CMD_RELOAD_TASKS) + End Using + _logger?.LogInfo("Sent CMD_RELOAD_TASKS to LiteTaskService") + Catch ex As InvalidOperationException + ' ServiceController throws this when the service is not installed. + ' Normal in GUI-only setups; nothing to do. + Catch ex As Exception + _logger?.LogWarning( + $"Could not notify LiteTaskService of task change: {ex.Message}. " & + "The service may keep stale task data until it is restarted. " & + "Grant SERVICE_USER_DEFINED_CONTROL to the GUI user or run the GUI elevated.") + End Try + End Sub + Private Sub UpdateTaskStatus(task As ScheduledTask) 'TODO: Implement task status update Try