From 7327965f221754d6a636e09e14660fe37563931a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 16:11:29 +0000 Subject: [PATCH] fix(service): notify LiteTaskService to reload tasks after GUI mutations The Windows service keeps its own in-memory _tasks dictionary and rewrites 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, toggle-enable, imports) until its next restart. After every task mutation the GUI now sends LiteTaskService.CMD_RELOAD_TASKS (custom service control 128) via ServiceController.ExecuteCommand. The service routes that signal through OnCustomCommand to a new CustomScheduler.ReloadTasks that evicts entries no longer on disk (skipping anything currently executing) and refreshes the rest from XML. Notification is best-effort: a non-admin GUI user without SERVICE_USER_DEFINED_CONTROL gets a warning in the log and the service stays stale until restart, but the GUI operation itself still succeeds. https://claude.ai/code/session_01B8GAryueT7MNRRhe4mB4gp --- CHANGELOG.md | 1 + Class/CustomScheduler.vb | 57 ++++++++++++++++++++++++++++++++++++++++ Class/LiteTaskService.vb | 20 ++++++++++++++ Forms/MainForm.vb | 34 ++++++++++++++++++++++++ 4 files changed, 112 insertions(+) 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