diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-22 09:21:06 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-22 09:21:06 +0300 |
| commit | 1888b70cf4981cabaef0b6e33e0ae9982d3a788e (patch) | |
| tree | 5cec4e67a05547b61ed03ffe7709902a80da3c35 | |
| parent | 02f5f1419c4cb5fccc47a0ce4dbf3957e73b0e79 (diff) | |
fix task export timeout for lq0
| -rw-r--r-- | internal/task/command_other.go | 7 | ||||
| -rw-r--r-- | internal/task/command_unix.go | 26 | ||||
| -rw-r--r-- | internal/task/task.go | 30 | ||||
| -rw-r--r-- | internal/task/task_test.go | 55 | ||||
| -rw-r--r-- | internal/ui/handlers.go | 4 | ||||
| -rw-r--r-- | internal/ui/keyactions.go | 8 | ||||
| -rw-r--r-- | internal/ui/table.go | 12 |
7 files changed, 124 insertions, 18 deletions
diff --git a/internal/task/command_other.go b/internal/task/command_other.go new file mode 100644 index 0000000..24b5234 --- /dev/null +++ b/internal/task/command_other.go @@ -0,0 +1,7 @@ +//go:build !unix + +package task + +import "os/exec" + +func configureCommandContext(*exec.Cmd) {} diff --git a/internal/task/command_unix.go b/internal/task/command_unix.go new file mode 100644 index 0000000..25e7fa3 --- /dev/null +++ b/internal/task/command_unix.go @@ -0,0 +1,26 @@ +//go:build unix + +package task + +import ( + "errors" + "os" + "os/exec" + "syscall" +) + +func configureCommandContext(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + if cmd.Process == nil { + return os.ErrProcessDone + } + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil { + if errors.Is(err, syscall.ESRCH) { + return os.ErrProcessDone + } + return err + } + return nil + } +} diff --git a/internal/task/task.go b/internal/task/task.go index 4eaff0e..ee9f0c9 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -188,10 +188,13 @@ func RunArgs(ctx context.Context, args []string) (RunResult, error) { } if dbg.writer != nil { - fmt.Fprintln(dbg.writer, "task "+strings.Join(copied, " ")) + if _, err := fmt.Fprintln(dbg.writer, "task "+strings.Join(copied, " ")); err != nil { + return result, fmt.Errorf("write debug log: %w", err) + } } cmd := exec.CommandContext(ctx, "task", copied...) + configureCommandContext(cmd) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr @@ -200,6 +203,9 @@ func RunArgs(ctx context.Context, args []string) (RunResult, error) { result.Stdout = stdout.String() result.Stderr = stderr.String() if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return result, fmt.Errorf("task command: %w", ctxErr) + } if strings.TrimSpace(result.Stderr) != "" { return result, fmt.Errorf("%v: %s", err, strings.TrimSpace(result.Stderr)) } @@ -247,20 +253,22 @@ func outputLines(output string) []string { return lines } -// Export retrieves all tasks using `task export rc.json.array=off` and parses -// the JSON output into a slice of Task structs. // Export retrieves tasks using `task <filter> export rc.json.array=off` and parses // the JSON output into a slice of Task structs. Optional filter arguments are // passed directly to the `task` command before `export`. -func Export(filters ...string) ([]Task, error) { +func Export(ctx context.Context, filters ...string) ([]Task, error) { args := append(filters, "export", "rc.json.array=off") - cmd := exec.Command("task", args...) + cmd := exec.CommandContext(ctx, "task", args...) + configureCommandContext(cmd) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, fmt.Errorf("task export: %w", ctxErr) + } // Include stderr output in the error message if stderr.Len() > 0 { return nil, fmt.Errorf("%v: %s", err, strings.TrimSpace(stderr.String())) @@ -300,11 +308,11 @@ func SetStatusUUID(uuid, status string) error { // RecurringSeries returns the recurring template and generated instances for // the recurring task identified by rootUUID. -func RecurringSeries(rootUUID string) ([]Task, error) { +func RecurringSeries(ctx context.Context, rootUUID string) ([]Task, error) { if strings.TrimSpace(rootUUID) == "" { return nil, fmt.Errorf("empty recurring task UUID") } - return Export(fmt.Sprintf("(%s or parent:%s)", rootUUID, rootUUID), "status.any:") + return Export(ctx, fmt.Sprintf("(%s or parent:%s)", rootUUID, rootUUID), "status.any:") } // Start begins the task with the given id. @@ -364,11 +372,11 @@ func RemoveTags(id int, tags []string) error { // SetTags sets the tags of the task with the given id to exactly the provided set. // Tags not present will be removed and new tags added as needed. -func SetTags(id int, tags []string) error { +func SetTags(ctx context.Context, id int, tags []string) error { if id <= 0 { return fmt.Errorf("invalid task ID: %d", id) } - tasks, err := Export(strconv.Itoa(id)) + tasks, err := Export(ctx, strconv.Itoa(id)) if err != nil { return err } @@ -455,11 +463,11 @@ func Denotate(id int, text string) error { // ReplaceAnnotations removes all existing annotations from the task with the // given id and sets a single annotation with the provided text. If text is // empty, all annotations are simply removed. -func ReplaceAnnotations(id int, text string) error { +func ReplaceAnnotations(ctx context.Context, id int, text string) error { if id <= 0 { return fmt.Errorf("invalid task ID: %d", id) } - tasks, err := Export(strconv.Itoa(id)) + tasks, err := Export(ctx, strconv.Itoa(id)) if err != nil { return err } diff --git a/internal/task/task_test.go b/internal/task/task_test.go index 838aa3a..59f94ba 100644 --- a/internal/task/task_test.go +++ b/internal/task/task_test.go @@ -2,12 +2,14 @@ package task import ( "context" + "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "testing" + "time" ) // TestSetDebugLog exercises the lifecycle of the debug logger: enable, @@ -88,7 +90,7 @@ func TestAddAndExport(t *testing.T) { t.Fatalf("add task 2: %v", err) } - tasks, err := Export() + tasks, err := Export(context.Background()) if err != nil { t.Fatalf("export: %v", err) } @@ -213,6 +215,53 @@ func TestRunLineReturnsCapturedErrorOutput(t *testing.T) { } } +func TestExportHonorsContextCancellation(t *testing.T) { + tmp := t.TempDir() + taskPath := filepath.Join(tmp, "task") + script := "#!/bin/sh\nsleep 5\n" + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + origPath := os.Getenv("PATH") + t.Setenv("PATH", tmp+":"+origPath) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + start := time.Now() + _, err := Export(ctx) + elapsed := time.Since(start) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Export error = %v, want context deadline exceeded", err) + } + if elapsed > time.Second { + t.Fatalf("Export took %s, expected prompt context cancellation", elapsed) + } +} + +func TestExportReturnsCapturedErrorOutput(t *testing.T) { + tmp := t.TempDir() + taskPath := filepath.Join(tmp, "task") + script := "#!/bin/sh\n" + + "echo export-failed >&2\n" + + "exit 2\n" + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + origPath := os.Getenv("PATH") + t.Setenv("PATH", tmp+":"+origPath) + + _, err := Export(context.Background()) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "export-failed") { + t.Fatalf("error did not include stderr: %v", err) + } +} + func TestLoadCompletionSources(t *testing.T) { tmp := t.TempDir() taskPath := filepath.Join(tmp, "task") @@ -279,7 +328,7 @@ func TestRecurringSeries(t *testing.T) { os.Setenv("PATH", tmp+":"+origPath) t.Cleanup(func() { os.Setenv("PATH", origPath) }) - tasks, err := RecurringSeries("parent-uuid") + tasks, err := RecurringSeries(context.Background(), "parent-uuid") if err != nil { t.Fatalf("RecurringSeries: %v", err) } @@ -335,7 +384,7 @@ func TestModifyHelpers(t *testing.T) { t.Fatalf("annotate: %v", err) } - tasks, err := Export() + tasks, err := Export(context.Background()) if err != nil { t.Fatalf("export: %v", err) } diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index 971561e..394aa80 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -47,7 +47,9 @@ func (m *Model) handleAnnotationMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } if m.replaceAnnotations { - if err := task.ReplaceAnnotations(m.annotateID, value); err != nil { + ctx, cancel := taskExportContext() + defer cancel() + if err := task.ReplaceAnnotations(ctx, m.annotateID, value); err != nil { return err } m.replaceAnnotations = false diff --git a/internal/ui/keyactions.go b/internal/ui/keyactions.go index 81ab468..072ea20 100644 --- a/internal/ui/keyactions.go +++ b/internal/ui/keyactions.go @@ -159,7 +159,9 @@ func (m *Model) handleUndo() (tea.Model, tea.Cmd) { } filters = append(filters, "status:"+restore.status) - tasks, err := task.Export(filters...) + ctx, cancel := taskExportContext() + tasks, err := task.Export(ctx, filters...) + cancel() if err == nil && len(tasks) > 0 { id = tasks[0].ID // Also update our local task list @@ -198,7 +200,9 @@ func (m *Model) deleteTaskWithUndo(tsk task.Task) (int, bool, error) { recurring := isRecurringTask(tsk) tasks := []task.Task{tsk} if recurring { - series, err := task.RecurringSeries(recurringRootUUID(tsk)) + ctx, cancel := taskExportContext() + series, err := task.RecurringSeries(ctx, recurringRootUUID(tsk)) + cancel() if err != nil { return 0, true, fmt.Errorf("loading recurring series: %w", err) } diff --git a/internal/ui/table.go b/internal/ui/table.go index 14b5c52..9aeeda1 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -1,6 +1,7 @@ package ui import ( + "context" "fmt" "os" "os/exec" @@ -24,6 +25,8 @@ import ( var priorityOptions = []string{"H", "M", "L", ""} +const taskExportTimeout = 30 * time.Second + var ( urlRegex = regexp.MustCompile(`https?://\S+`) searchRegexCache = make(map[string]*regexp.Regexp, 16) @@ -259,6 +262,10 @@ type reloadData struct { ultraFilterIDs []int } +func taskExportContext() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), taskExportTimeout) +} + // blinkInterval controls how quickly the row flashes when a task changes. // A shorter interval results in a faster blink. const blinkInterval = 150 * time.Millisecond @@ -484,7 +491,10 @@ func (m *Model) fetchTasks() (reloadData, error) { // Always show only pending tasks by default. filters := append([]string(nil), m.filters...) filters = append(filters, "status:pending") - tasks, err := task.Export(filters...) + ctx, cancel := taskExportContext() + defer cancel() + + tasks, err := task.Export(ctx, filters...) if err != nil { return reloadData{}, err } |
