diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-22 13:58:51 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-22 13:58:51 +0300 |
| commit | b83ee79cc9372f2fa51daf71757a1213328e1723 (patch) | |
| tree | 56db0a451dbe7bbe152e6dab0f13de34929d593c | |
| parent | c3f3c356beda685e0327fd7346b43310fba990ab (diff) | |
Fix lq0 delete rollback cancellation
| -rw-r--r-- | internal/ui/keyactions.go | 16 | ||||
| -rw-r--r-- | internal/ui/table_test.go | 113 |
2 files changed, 126 insertions, 3 deletions
diff --git a/internal/ui/keyactions.go b/internal/ui/keyactions.go index 4d8c682..e87f34a 100644 --- a/internal/ui/keyactions.go +++ b/internal/ui/keyactions.go @@ -2,6 +2,7 @@ package ui import ( "context" + "errors" "fmt" "math/rand" "os/exec" @@ -234,7 +235,9 @@ func (m *Model) deleteTaskWithUndo(tsk task.Task) (int, bool, error) { completed := make([]undoRestore, 0, len(restores)) for _, restore := range restores { if err := task.SetStatusUUIDContext(ctx, restore.uuid, "deleted"); err != nil { - rollbackUndoRestores(ctx, completed) + if rollbackErr := rollbackUndoRestores(completed); rollbackErr != nil { + return 0, recurring, fmt.Errorf("deleting task %s: %w; rollback failed: %w", restore.uuid, err, rollbackErr) + } return 0, recurring, fmt.Errorf("deleting task %s: %w", restore.uuid, err) } completed = append(completed, restore) @@ -304,10 +307,17 @@ func undoStatusForTask(tsk task.Task) string { return tsk.Status } -func rollbackUndoRestores(ctx context.Context, restores []undoRestore) { +func rollbackUndoRestores(restores []undoRestore) error { + ctx, cancel := context.WithTimeout(context.Background(), taskOperationTimeout) + defer cancel() + + var errs []error for i := len(restores) - 1; i >= 0; i-- { - _ = task.SetStatusUUIDContext(ctx, restores[i].uuid, restores[i].status) + if err := task.SetStatusUUIDContext(ctx, restores[i].uuid, restores[i].status); err != nil { + errs = append(errs, fmt.Errorf("restoring task %s to %s: %w", restores[i].uuid, restores[i].status, err)) + } } + return errors.Join(errs...) } func undoStatus(action undoAction) string { diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index 8880a03..5bb3440 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -550,6 +550,119 @@ func TestDeleteRecurringHotkeyUndo(t *testing.T) { } } +func TestDeleteRecurringRollsBackCompletedDeletesAfterContextDeadline(t *testing.T) { + tmp := t.TempDir() + taskPath := filepath.Join(tmp, "task") + logFile := filepath.Join(tmp, "log.txt") + + script := fmt.Sprintf("#!/bin/sh\n"+ + "if [ \"$1\" = \"(parent or parent:parent)\" ] && [ \"$2\" = \"status.any:\" ] && [ \"$3\" = \"export\" ]; then\n"+ + " echo '{\"id\":0,\"uuid\":\"parent\",\"description\":\"template\",\"status\":\"recurring\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0,\"recur\":\"daily\",\"rtype\":\"periodic\"}'\n"+ + " echo '{\"id\":1,\"uuid\":\"child\",\"parent\":\"parent\",\"description\":\"child\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0,\"recur\":\"daily\",\"rtype\":\"periodic\"}'\n"+ + " exit 0\n"+ + "fi\n"+ + "echo \"$@\" >> %q\n"+ + "if [ \"$1\" = \"parent\" ] && [ \"$2\" = \"modify\" ] && [ \"$3\" = \"status:deleted\" ]; then\n"+ + " sleep 10\n"+ + "fi\n", logFile) + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + setupEnv(t, taskPath) + + parentCtx, cancelParent := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancelParent() + m := Model{taskContext: parentCtx, cancelTaskContext: cancelParent} + + count, recurring, err := m.deleteTaskWithUndo(task.Task{ + ID: 1, + UUID: "child", + Parent: "parent", + Description: "child", + Status: "pending", + Recur: "daily", + RType: "periodic", + }) + if err == nil { + t.Fatal("deleteTaskWithUndo returned nil error; want context deadline error") + } + if !strings.Contains(err.Error(), context.DeadlineExceeded.Error()) { + t.Fatalf("error = %q, want context deadline exceeded", err) + } + if count != 0 || !recurring { + t.Fatalf("delete result = (%d, %v), want (0, true)", count, recurring) + } + if len(m.undoStack) != 0 { + t.Fatalf("undo stack length = %d, want 0", len(m.undoStack)) + } + + data, err := os.ReadFile(logFile) + if err != nil { + t.Fatalf("read log: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + want := []string{ + "child modify status:deleted", + "parent modify status:deleted", + "child modify status:pending", + } + if !reflect.DeepEqual(lines, want) { + t.Fatalf("unexpected commands:\ngot %#v\nwant %#v", lines, want) + } +} + +func TestDeleteRecurringReportsRollbackFailure(t *testing.T) { + tmp := t.TempDir() + taskPath := filepath.Join(tmp, "task") + logFile := filepath.Join(tmp, "log.txt") + + script := fmt.Sprintf("#!/bin/sh\n"+ + "if [ \"$1\" = \"(parent or parent:parent)\" ] && [ \"$2\" = \"status.any:\" ] && [ \"$3\" = \"export\" ]; then\n"+ + " echo '{\"id\":0,\"uuid\":\"parent\",\"description\":\"template\",\"status\":\"recurring\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0,\"recur\":\"daily\",\"rtype\":\"periodic\"}'\n"+ + " echo '{\"id\":1,\"uuid\":\"child\",\"parent\":\"parent\",\"description\":\"child\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0,\"recur\":\"daily\",\"rtype\":\"periodic\"}'\n"+ + " exit 0\n"+ + "fi\n"+ + "echo \"$@\" >> %q\n"+ + "if [ \"$1\" = \"parent\" ] && [ \"$2\" = \"modify\" ] && [ \"$3\" = \"status:deleted\" ]; then\n"+ + " echo delete failed >&2\n"+ + " exit 2\n"+ + "fi\n"+ + "if [ \"$1\" = \"child\" ] && [ \"$2\" = \"modify\" ] && [ \"$3\" = \"status:pending\" ]; then\n"+ + " echo rollback failed >&2\n"+ + " exit 3\n"+ + "fi\n", logFile) + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + setupEnv(t, taskPath) + + m := Model{} + count, recurring, err := m.deleteTaskWithUndo(task.Task{ + ID: 1, + UUID: "child", + Parent: "parent", + Description: "child", + Status: "pending", + Recur: "daily", + RType: "periodic", + }) + if err == nil { + t.Fatal("deleteTaskWithUndo returned nil error; want rollback failure") + } + if !strings.Contains(err.Error(), "rollback failed") { + t.Fatalf("error = %q, want rollback failure detail", err) + } + if !strings.Contains(err.Error(), "restoring task child to pending") { + t.Fatalf("error = %q, want failed restore context", err) + } + if count != 0 || !recurring { + t.Fatalf("delete result = (%d, %v), want (0, true)", count, recurring) + } + if len(m.undoStack) != 0 { + t.Fatalf("undo stack length = %d, want 0", len(m.undoStack)) + } +} + func TestDeleteHotkeyInUltraMode(t *testing.T) { tmp := t.TempDir() taskPath := filepath.Join(tmp, "task") |
