summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-22 13:58:51 +0300
committerPaul Buetow <paul@buetow.org>2026-06-22 13:58:51 +0300
commitb83ee79cc9372f2fa51daf71757a1213328e1723 (patch)
tree56db0a451dbe7bbe152e6dab0f13de34929d593c /internal
parentc3f3c356beda685e0327fd7346b43310fba990ab (diff)
Fix lq0 delete rollback cancellation
Diffstat (limited to 'internal')
-rw-r--r--internal/ui/keyactions.go16
-rw-r--r--internal/ui/table_test.go113
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")