summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-22 09:21:06 +0300
committerPaul Buetow <paul@buetow.org>2026-06-22 09:21:06 +0300
commit1888b70cf4981cabaef0b6e33e0ae9982d3a788e (patch)
tree5cec4e67a05547b61ed03ffe7709902a80da3c35
parent02f5f1419c4cb5fccc47a0ce4dbf3957e73b0e79 (diff)
fix task export timeout for lq0
-rw-r--r--internal/task/command_other.go7
-rw-r--r--internal/task/command_unix.go26
-rw-r--r--internal/task/task.go30
-rw-r--r--internal/task/task_test.go55
-rw-r--r--internal/ui/handlers.go4
-rw-r--r--internal/ui/keyactions.go8
-rw-r--r--internal/ui/table.go12
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
}