From 289aec6dfdc1ebdb36a9818c4a8057effe953ee1 Mon Sep 17 00:00:00 2001 From: "Stanislav (Stas) Katkov" Date: Tue, 12 May 2026 22:29:23 +0200 Subject: [PATCH 1/5] reproduce a race condition --- .github/workflows/test.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a7a68e4..45d65d9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,6 +29,4 @@ jobs: run: go mod download - name: Run tests - run: go test - - + run: go test -race From db42bf7ca0de6b166a6feaaec8f03d5f796c49f5 Mon Sep 17 00:00:00 2001 From: "Stanislav (Stas) Katkov" Date: Tue, 12 May 2026 22:39:31 +0200 Subject: [PATCH 2/5] fix: synchronize global program access Guard the package-level Bubble Tea program with a mutex so task goroutines cannot race with Run while it publishes or clears the program pointer. Route program sends through a helper that snapshots the pointer under the same lock before sending. --- taskin.go | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/taskin.go b/taskin.go index 3dae290..308c1b3 100644 --- a/taskin.go +++ b/taskin.go @@ -6,6 +6,7 @@ import ( "io" "os" "regexp" + "sync" "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/spinner" @@ -13,7 +14,25 @@ import ( "github.com/charmbracelet/lipgloss" ) -var program *tea.Program +var ( + programMu sync.RWMutex + program *tea.Program +) + +func setProgram(p *tea.Program) { + programMu.Lock() + defer programMu.Unlock() + program = p +} + +func sendProgramMessage(msg tea.Msg) { + programMu.RLock() + p := program + programMu.RUnlock() + if p != nil { + p.Send(msg) + } +} func NewRunner(task Task, cfg Config) Runner { @@ -78,8 +97,10 @@ func (r *Runners) Run() error { out = &ansiEscapeCodeFilter{writer: out} } - program = tea.NewProgram(m, tea.WithInput(nil), tea.WithOutput(out)) - _, err := program.Run() + p := tea.NewProgram(m, tea.WithInput(nil), tea.WithOutput(out)) + setProgram(p) + _, err := p.Run() + setProgram(nil) if err != nil { return fmt.Errorf("program run error: %w", err) } @@ -124,9 +145,7 @@ func New(tasks Tasks, cfg Config) Runners { } runner.State = Completed - if program != nil { - program.Send(spinner.TickMsg{}) - } + sendProgramMessage(spinner.TickMsg{}) return nil } @@ -140,8 +159,8 @@ func New(tasks Tasks, cfg Config) Runners { } err := runTaskAndChildren(&runners[i]) - if err != nil && program != nil { - program.Send(TerminateWithError{Error: err}) + if err != nil { + sendProgramMessage(TerminateWithError{Error: err}) } } }() From 5b90e2333e1a637493360958563e8f0c1c26c17e Mon Sep 17 00:00:00 2001 From: "Stanislav (Stas) Katkov" Date: Tue, 12 May 2026 22:41:18 +0200 Subject: [PATCH 3/5] fix: serialize task state updates through model Start task execution from the Bubble Tea model and route task lifecycle, title, progress, completion, and failure updates through messages handled by Model.Update. Tasks now mutate private snapshots and use SetTitle/SetHideView/Progress to publish safe updates, so rendering no longer reads runner/task fields concurrently with background writes. --- examples/custom/main.go | 6 +- examples/disable-ui/main.go | 6 +- examples/error/main.go | 4 +- examples/multi/main.go | 12 +-- examples/progress/main.go | 2 +- examples/simple/main.go | 4 +- models.go | 24 +++++ mvc.go | 65 +++++++++++- readme.md | 12 +-- taskin.go | 199 +++++++++++++++++++++++++----------- taskin_test.go | 40 +++++++- 11 files changed, 283 insertions(+), 91 deletions(-) diff --git a/examples/custom/main.go b/examples/custom/main.go index 64c3aeb..b86eae1 100644 --- a/examples/custom/main.go +++ b/examples/custom/main.go @@ -16,7 +16,7 @@ func main() { // sleep for 3 seconds then return nil Task: func(t *taskin.Task) error { for i := 0; i < 2; i++ { - t.Title = fmt.Sprintf("Task 1 - [%d/%d]", i+1, 2) + t.SetTitle(fmt.Sprintf("Task 1 - [%d/%d]", i+1, 2)) time.Sleep(1 * time.Second) } return nil @@ -28,7 +28,7 @@ func main() { Task: func(t *taskin.Task) error { for i := 0; i < 5; i++ { t.Progress(i+1, 5) - t.Title = fmt.Sprintf("Task 2 - [%d/%d]", i+1, 5) + t.SetTitle(fmt.Sprintf("Task 2 - [%d/%d]", i+1, 5)) time.Sleep(1 * time.Second) } return nil @@ -39,7 +39,7 @@ func main() { // sleep for 3 seconds then return nil Task: func(t *taskin.Task) error { for i := 0; i < 2; i++ { - t.Title = fmt.Sprintf("Task 3 - [%d/%d]", i+1, 2) + t.SetTitle(fmt.Sprintf("Task 3 - [%d/%d]", i+1, 2)) time.Sleep(1 * time.Second) } return nil diff --git a/examples/disable-ui/main.go b/examples/disable-ui/main.go index 22583b8..5f42566 100644 --- a/examples/disable-ui/main.go +++ b/examples/disable-ui/main.go @@ -13,7 +13,7 @@ func main() { Title: "Task with UI disabled", Task: func(t *taskin.Task) error { for i := 0; i < 3; i++ { - t.Title = fmt.Sprintf("Task with UI disabled: [%d/3] processing", i+1) + t.SetTitle(fmt.Sprintf("Task with UI disabled: [%d/3] processing", i+1)) time.Sleep(500 * time.Millisecond) } return nil @@ -26,7 +26,7 @@ func main() { Title: "Child task 1", Task: func(t *taskin.Task) error { for i := 0; i < 2; i++ { - t.Title = fmt.Sprintf("Child task 1: [%d/2] working", i+1) + t.SetTitle(fmt.Sprintf("Child task 1: [%d/2] working", i+1)) time.Sleep(300 * time.Millisecond) } return nil @@ -36,7 +36,7 @@ func main() { Title: "Child task 2", Task: func(t *taskin.Task) error { for i := 0; i < 2; i++ { - t.Title = fmt.Sprintf("Child task 2: [%d/2] working", i+1) + t.SetTitle(fmt.Sprintf("Child task 2: [%d/2] working", i+1)) time.Sleep(300 * time.Millisecond) } return nil diff --git a/examples/error/main.go b/examples/error/main.go index d7d4ef9..fe72737 100644 --- a/examples/error/main.go +++ b/examples/error/main.go @@ -13,7 +13,7 @@ func main() { Title: "Task 1", Task: func(t *taskin.Task) error { for i := 0; i < 3; i++ { - t.Title = fmt.Sprintf("Task 1: [%d/3] seconds have passed", i+1) + t.SetTitle(fmt.Sprintf("Task 1: [%d/3] seconds have passed", i+1)) time.Sleep(500 * time.Millisecond) } return nil @@ -29,7 +29,7 @@ func main() { Title: "Task 3", Task: func(t *taskin.Task) error { for i := 0; i < 3; i++ { - t.Title = fmt.Sprintf("Task 3: [%d/3] seconds have passed", i+1) + t.SetTitle(fmt.Sprintf("Task 3: [%d/3] seconds have passed", i+1)) time.Sleep(500 * time.Millisecond) } return nil diff --git a/examples/multi/main.go b/examples/multi/main.go index 164e737..505789c 100644 --- a/examples/multi/main.go +++ b/examples/multi/main.go @@ -13,7 +13,7 @@ func main() { Title: "Mow the lawn", Task: func(t *taskin.Task) error { for i := 0; i < 3; i++ { - t.Title = fmt.Sprintf("Mow the lawn: [%d/3] passes", i+1) + t.SetTitle(fmt.Sprintf("Mow the lawn: [%d/3] passes", i+1)) time.Sleep(500 * time.Millisecond) } return nil @@ -26,7 +26,7 @@ func main() { Title: "Pluck the silkies [0/3]", Task: func(t *taskin.Task) error { for i := 0; i < 3; i++ { - t.Title = fmt.Sprintf("Pluck the silkies [%d/3]", i+1) + t.SetTitle(fmt.Sprintf("Pluck the silkies [%d/3]", i+1)) time.Sleep(500 * time.Millisecond) } return nil @@ -40,7 +40,7 @@ func main() { Title: "[0/3] Pluck the Polish", Task: func(t *taskin.Task) error { for i := 0; i < 3; i++ { - t.Title = fmt.Sprintf("[%d/3] Pluck the Polish", i+1) + t.SetTitle(fmt.Sprintf("[%d/3] Pluck the Polish", i+1)) time.Sleep(500 * time.Millisecond) } return nil @@ -50,7 +50,7 @@ func main() { Title: "[0/3] Pluck the Marans", Task: func(t *taskin.Task) error { for i := 0; i < 3; i++ { - t.Title = fmt.Sprintf("[%d/3] Pluck the Marans", i+1) + t.SetTitle(fmt.Sprintf("[%d/3] Pluck the Marans", i+1)) time.Sleep(500 * time.Millisecond) } return nil @@ -63,7 +63,7 @@ func main() { Title: "[0/3] Pluck the leghorns", Task: func(t *taskin.Task) error { for i := 0; i < 3; i++ { - t.Title = fmt.Sprintf("[%d/3] Pluck the Leghorns", i+1) + t.SetTitle(fmt.Sprintf("[%d/3] Pluck the Leghorns", i+1)) time.Sleep(500 * time.Millisecond) } return nil @@ -76,7 +76,7 @@ func main() { Task: func(t *taskin.Task) error { for i := 0; i < 3; i++ { t.Progress(i+1, 3) - t.Title = fmt.Sprintf("Paint the house: [%d/3] walls painted", i+1) + t.SetTitle(fmt.Sprintf("Paint the house: [%d/3] walls painted", i+1)) time.Sleep(500 * time.Millisecond) } return nil diff --git a/examples/progress/main.go b/examples/progress/main.go index f30e3a7..c49787e 100644 --- a/examples/progress/main.go +++ b/examples/progress/main.go @@ -14,7 +14,7 @@ func main() { Task: func(t *taskin.Task) error { for i := 0; i < 5; i++ { t.Progress(i+1, 5) - t.Title = fmt.Sprintf("Progress [%d/%d]", i+1, 5) + t.SetTitle(fmt.Sprintf("Progress [%d/%d]", i+1, 5)) time.Sleep(1 * time.Second) } return nil diff --git a/examples/simple/main.go b/examples/simple/main.go index 3ffaab3..f934385 100644 --- a/examples/simple/main.go +++ b/examples/simple/main.go @@ -13,7 +13,7 @@ func main() { Title: "Task 1", Task: func(t *taskin.Task) error { for i := 0; i < 3; i++ { - t.Title = fmt.Sprintf("Task 1: [%d/3] seconds have passed", i+1) + t.SetTitle(fmt.Sprintf("Task 1: [%d/3] seconds have passed", i+1)) time.Sleep(500 * time.Millisecond) } return nil @@ -23,7 +23,7 @@ func main() { Title: "Task 2", Task: func(t *taskin.Task) error { for i := 0; i < 3; i++ { - t.Title = fmt.Sprintf("Task 2: [%d/3] seconds have passed", i+1) + t.SetTitle(fmt.Sprintf("Task 2: [%d/3] seconds have passed", i+1)) time.Sleep(500 * time.Millisecond) } return nil diff --git a/models.go b/models.go index 5466290..592f1a1 100644 --- a/models.go +++ b/models.go @@ -3,12 +3,35 @@ package taskin import ( "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" ) type TerminateWithError struct { Error error } +type taskStartedMsg struct { + Path []int +} + +type taskUpdatedMsg struct { + Path []int + Task Task +} + +type taskCompletedMsg struct { + Path []int + Task Task +} + +type taskFailedMsg struct { + Path []int + Task Task + Error error +} + +type taskExecutionFinishedMsg struct{} + type TaskState int const ( @@ -50,4 +73,5 @@ type Model struct { HideView bool Shutdown bool ShutdownError error + taskMessages chan tea.Msg } diff --git a/mvc.go b/mvc.go index d5f0039..f459e9d 100644 --- a/mvc.go +++ b/mvc.go @@ -19,9 +19,25 @@ func (m *Model) Init() tea.Cmd { } } } + if m.taskMessages != nil { + cmds = append(cmds, runTasksCmd(cloneRunners(m.Runners), m.taskMessages), waitForTaskMessage(m.taskMessages)) + } return tea.Batch(cmds...) } +func waitForTaskMessage(messages <-chan tea.Msg) tea.Cmd { + return func() tea.Msg { + return <-messages + } +} + +func (m *Model) waitForTaskMessage() tea.Cmd { + if m.taskMessages == nil { + return nil + } + return waitForTaskMessage(m.taskMessages) +} + func (m *Model) SetShutdown(err error) { m.Shutdown = true m.ShutdownError = err @@ -38,6 +54,34 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case TerminateWithError: m.SetShutdown(msg.Error) return m, tea.Quit + case taskExecutionFinishedMsg: + return m, tea.Quit + case taskStartedMsg: + if runner := m.runnerAtPath(msg.Path); runner != nil { + runner.State = Running + } + return m, m.waitForTaskMessage() + case taskUpdatedMsg: + if runner := m.runnerAtPath(msg.Path); runner != nil { + runner.Task = msg.Task + } + return m, m.waitForTaskMessage() + case taskCompletedMsg: + if runner := m.runnerAtPath(msg.Path); runner != nil { + runner.Task = msg.Task + runner.State = Completed + } + allDone, anyFailed := m.checkTasksState() + if allDone && !anyFailed { + return m, tea.Quit + } + return m, m.waitForTaskMessage() + case taskFailedMsg: + if runner := m.runnerAtPath(msg.Path); runner != nil { + runner.Task = msg.Task + runner.State = Failed + } + return m, m.waitForTaskMessage() case spinner.TickMsg: // Helper function to update spinners recursively @@ -65,16 +109,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { for i := range m.Runners { cmds = append(cmds, updateSpinners(&m.Runners[i])...) - if m.Runners[i].State == Failed { - return m, tea.Quit - } - if m.Runners[i].State != Completed && m.Runners[i].State != Failed { allDone = false } } - if allDone { + _, anyFailed := m.checkTasksState() + if allDone && !anyFailed { return m, tea.Quit } @@ -84,6 +125,20 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +func (m *Model) runnerAtPath(path []int) *Runner { + if len(path) == 0 || path[0] < 0 || path[0] >= len(m.Runners) { + return nil + } + runner := &m.Runners[path[0]] + for _, index := range path[1:] { + if index < 0 || index >= len(runner.Children) { + return nil + } + runner = &runner.Children[index] + } + return runner +} + func (m *Model) checkTasksState() (allDone, anyFailed bool) { allDone = true for _, runner := range m.Runners { diff --git a/readme.md b/readme.md index 3e0e77a..663e004 100644 --- a/readme.md +++ b/readme.md @@ -74,21 +74,21 @@ https://github.com/fumeapp/taskin/blob/3cd766c21e5eaba5edb33f38d3781d6cf814f9f9/ The `*taskin.Task` struct passeed into your task has some useful properties that you can use to customize the task view. ### Change the title -Already demonstrated in most of the examples, you can change `t.Title` at any time +Already demonstrated in most of the examples, you can change the title with `t.SetTitle("New title")`. ### Hide a view Sometimes you might need to temporarily hide you task view in order to prompt a user for input. -You can do this by toggling the task.HideView boolean. +You can do this by calling `t.SetHideView(true)` and showing it again with `t.SetHideView(false)`. ```go Task: func(T *taskin.Task ) error { - t.HideView = true + t.SetHideView(true) if err := PromptForInput(); err != nil { - t.HideView = false + t.SetHideView(false) return err } - t.HideView = false - t.Title = "Input received" + t.SetHideView(false) + t.SetTitle("Input received") return nil } diff --git a/taskin.go b/taskin.go index 308c1b3..7d8e0e1 100644 --- a/taskin.go +++ b/taskin.go @@ -14,25 +14,9 @@ import ( "github.com/charmbracelet/lipgloss" ) -var ( - programMu sync.RWMutex - program *tea.Program -) +type taskUpdateCallback func(Task) -func setProgram(p *tea.Program) { - programMu.Lock() - defer programMu.Unlock() - program = p -} - -func sendProgramMessage(msg tea.Msg) { - programMu.RLock() - p := program - programMu.RUnlock() - if p != nil { - p.Send(msg) - } -} +var taskUpdateCallbacks sync.Map func NewRunner(task Task, cfg Config) Runner { @@ -62,19 +46,42 @@ func NewRunner(task Task, cfg Config) Runner { } func (task *Task) Progress(current, total int) { - task.ShowProgress = TaskProgress{Current: current, Total: total} - if IsCI() { + task.applyProgress(TaskProgress{Current: current, Total: total}) + task.notifyUpdate() +} + +func (task *Task) SetTitle(title string) { + task.Title = title + task.notifyUpdate() +} + +func (task *Task) SetHideView(hide bool) { + task.HideView = hide + task.notifyUpdate() +} + +func (task *Task) applyProgress(taskProgress TaskProgress) { + task.ShowProgress = taskProgress + if IsCI() || task.Config.DisableUI { return } if !task.Bar.IsAnimating() { task.Bar = progress.New(task.Config.ProgressOptions...) } - if total != 0 { // Check if TaskProgress is set - percent := float64(current) / float64(total) + if taskProgress.Total != 0 { // Check if TaskProgress is set + percent := float64(taskProgress.Current) / float64(taskProgress.Total) task.Bar.SetPercent(percent) } } +func (task *Task) notifyUpdate() { + callback, ok := taskUpdateCallbacks.Load(task) + if !ok { + return + } + callback.(taskUpdateCallback)(snapshotTask(*task)) +} + type ansiEscapeCodeFilter struct { writer io.Writer } @@ -89,7 +96,7 @@ func (f *ansiEscapeCodeFilter) Write(p []byte) (n int, err error) { } func (r *Runners) Run() error { - m := &Model{Runners: *r, Shutdown: false, ShutdownError: nil} + m := &Model{Runners: cloneRunners(*r), Shutdown: false, ShutdownError: nil, taskMessages: make(chan tea.Msg, 64)} var out io.Writer = os.Stdout // Check if we need to disable UI features or are in CI mode @@ -97,10 +104,9 @@ func (r *Runners) Run() error { out = &ansiEscapeCodeFilter{writer: out} } - p := tea.NewProgram(m, tea.WithInput(nil), tea.WithOutput(out)) - setProgram(p) - _, err := p.Run() - setProgram(nil) + program := tea.NewProgram(m, tea.WithInput(nil), tea.WithOutput(out)) + _, err := program.Run() + *r = m.Runners if err != nil { return fmt.Errorf("program run error: %w", err) } @@ -120,52 +126,121 @@ func New(tasks Tasks, cfg Config) Runners { runners = append(runners, NewRunner(task, cfg)) } - // Helper function to run a task and its children recursively - var runTaskAndChildren func(runner *Runner) error - runTaskAndChildren = func(runner *Runner) error { - runner.State = Running + return runners +} - // Run the task itself first if it has a function - if runner.Task.Task != nil { - err := runner.Task.Task(&runner.Task) - if err != nil { - runner.Task.Title = fmt.Sprintf("%s - %s", runner.Task.Title, err.Error()) - runner.State = Failed - return err +func runTasksCmd(runners Runners, messages chan<- tea.Msg) tea.Cmd { + return func() tea.Msg { + go func() { + if err := runRunners(runners, messages); err != nil { + messages <- TerminateWithError{Error: err} + return } + messages <- taskExecutionFinishedMsg{} + }() + return nil + } +} + +func runRunners(runners Runners, messages chan<- tea.Msg) error { + var firstErr error + for i := range runners { + err := runTaskAndChildren(&runners[i], pathWithIndex(nil, i), messages) + if err == nil { + continue + } + if firstErr == nil { + firstErr = err } + if runners[i].Config.Options.ExitOnFailure { + return firstErr + } + } + return firstErr +} - // Run all children recursively - for i := range runner.Children { - err := runTaskAndChildren(&runner.Children[i]) - if err != nil { - runner.State = Failed - return err - } +func runTaskAndChildren(runner *Runner, path []int, messages chan<- tea.Msg) error { + runner.State = Running + messages <- taskStartedMsg{Path: clonePath(path)} + + task := runner.Task + callback := taskUpdateCallback(func(updated Task) { + messages <- taskUpdatedMsg{Path: clonePath(path), Task: updated} + }) + taskUpdateCallbacks.Store(&task, callback) + var err error + if task.Task != nil { + err = task.Task(&task) + } + taskUpdateCallbacks.Delete(&task) + + runner.Task = snapshotTask(task) + if err != nil { + runner.Task.Title = fmt.Sprintf("%s - %s", runner.Task.Title, err.Error()) + runner.State = Failed + messages <- taskFailedMsg{Path: clonePath(path), Task: runner.Task, Error: err} + return err + } + messages <- taskUpdatedMsg{Path: clonePath(path), Task: runner.Task} + + for i := range runner.Children { + if err := runTaskAndChildren(&runner.Children[i], pathWithIndex(path, i), messages); err != nil { + runner.State = Failed + messages <- taskFailedMsg{Path: clonePath(path), Task: runner.Task, Error: err} + return err } + } + + runner.State = Completed + messages <- taskCompletedMsg{Path: clonePath(path), Task: runner.Task} + return nil +} - runner.State = Completed - sendProgramMessage(spinner.TickMsg{}) +func cloneRunners(runners Runners) Runners { + if runners == nil { return nil } + cloned := make(Runners, len(runners)) + for i := range runners { + cloned[i] = runners[i] + cloned[i].Task = snapshotTask(runners[i].Task) + cloned[i].Children = cloneRunners(runners[i].Children) + if runners[i].Spinner != nil { + spinnerCopy := *runners[i].Spinner + cloned[i].Spinner = &spinnerCopy + } + } + return cloned +} - go func() { - for i := range runners { - // Check for previous failures - for _, prev := range runners[:i] { - if prev.State == Failed && prev.Config.Options.ExitOnFailure { - return - } - } +func snapshotTask(task Task) Task { + task.Tasks = cloneTasks(task.Tasks) + return task +} - err := runTaskAndChildren(&runners[i]) - if err != nil { - sendProgramMessage(TerminateWithError{Error: err}) - } - } - }() +func cloneTasks(tasks Tasks) Tasks { + if tasks == nil { + return nil + } + cloned := make(Tasks, len(tasks)) + for i := range tasks { + cloned[i] = tasks[i] + cloned[i].Tasks = cloneTasks(tasks[i].Tasks) + } + return cloned +} - return runners +func pathWithIndex(path []int, index int) []int { + next := make([]int, len(path)+1) + copy(next, path) + next[len(path)] = index + return next +} + +func clonePath(path []int) []int { + cloned := make([]int, len(path)) + copy(cloned, path) + return cloned } func IsCI() bool { diff --git a/taskin_test.go b/taskin_test.go index 65bd20e..6ac7cde 100644 --- a/taskin_test.go +++ b/taskin_test.go @@ -46,10 +46,14 @@ func TestRunnersRun(t *testing.T) { } func TestNew(t *testing.T) { + ran := false tasks := Tasks{ Task{ Title: "Test Task", - Task: func(t *Task) error { return nil }, + Task: func(t *Task) error { + ran = true + return nil + }, }, } cfg := Config{ @@ -61,6 +65,10 @@ func TestNew(t *testing.T) { if len(runners) != 1 { t.Errorf("Expected New to return 1 runner, got '%d'", len(runners)) } + + if ran { + t.Error("Expected New not to start tasks") + } } func TestTaskProgress(t *testing.T) { @@ -79,3 +87,33 @@ func TestTaskProgress(t *testing.T) { t.Errorf("Expected TaskProgress to be 1/10, got %d/%d", runner.Task.ShowProgress.Current, runner.Task.ShowProgress.Total) } } + +func TestRunAppliesTaskUpdates(t *testing.T) { + tasks := Tasks{ + { + Title: "Test Task", + Task: func(t *Task) error { + t.SetTitle("Updated Task") + t.Progress(2, 4) + return nil + }, + }, + } + cfg := Defaults + cfg.DisableUI = true + + runners := New(tasks, cfg) + if err := runners.Run(); err != nil { + t.Fatalf("Expected Run to return nil, got %s", err.Error()) + } + + if runners[0].State != Completed { + t.Fatalf("Expected runner state to be completed, got %d", runners[0].State) + } + if runners[0].Task.Title != "Updated Task" { + t.Fatalf("Expected task title update, got %q", runners[0].Task.Title) + } + if runners[0].Task.ShowProgress.Current != 2 || runners[0].Task.ShowProgress.Total != 4 { + t.Fatalf("Expected progress 2/4, got %d/%d", runners[0].Task.ShowProgress.Current, runners[0].Task.ShowProgress.Total) + } +} From 02b02f2c3a3484684d475de1aef89ff52244d0df Mon Sep 17 00:00:00 2001 From: "Stanislav (Stas) Katkov" Date: Tue, 12 May 2026 22:47:09 +0200 Subject: [PATCH 4/5] simplify --- models.go | 7 ++----- mvc.go | 16 ++++++---------- taskin.go | 20 ++++++-------------- 3 files changed, 14 insertions(+), 29 deletions(-) diff --git a/models.go b/models.go index 592f1a1..07361ca 100644 --- a/models.go +++ b/models.go @@ -25,13 +25,10 @@ type taskCompletedMsg struct { } type taskFailedMsg struct { - Path []int - Task Task - Error error + Path []int + Task Task } -type taskExecutionFinishedMsg struct{} - type TaskState int const ( diff --git a/mvc.go b/mvc.go index f459e9d..29ad5cd 100644 --- a/mvc.go +++ b/mvc.go @@ -20,6 +20,9 @@ func (m *Model) Init() tea.Cmd { } } if m.taskMessages != nil { + if len(m.Runners) == 0 { + return tea.Quit + } cmds = append(cmds, runTasksCmd(cloneRunners(m.Runners), m.taskMessages), waitForTaskMessage(m.taskMessages)) } return tea.Batch(cmds...) @@ -44,8 +47,6 @@ func (m *Model) SetShutdown(err error) { } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - if m.Shutdown && m.ShutdownError != nil { return m, tea.Quit } @@ -54,8 +55,6 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case TerminateWithError: m.SetShutdown(msg.Error) return m, tea.Quit - case taskExecutionFinishedMsg: - return m, tea.Quit case taskStartedMsg: if runner := m.runnerAtPath(msg.Path); runner != nil { runner.State = Running @@ -84,6 +83,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.waitForTaskMessage() case spinner.TickMsg: + var cmds []tea.Cmd + // Helper function to update spinners recursively var updateSpinners func(runner *Runner) []tea.Cmd updateSpinners = func(runner *Runner) []tea.Cmd { @@ -105,16 +106,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return spinnerCmds } - allDone := true for i := range m.Runners { cmds = append(cmds, updateSpinners(&m.Runners[i])...) - - if m.Runners[i].State != Completed && m.Runners[i].State != Failed { - allDone = false - } } - _, anyFailed := m.checkTasksState() + allDone, anyFailed := m.checkTasksState() if allDone && !anyFailed { return m, tea.Quit } diff --git a/taskin.go b/taskin.go index 7d8e0e1..20d1073 100644 --- a/taskin.go +++ b/taskin.go @@ -134,9 +134,7 @@ func runTasksCmd(runners Runners, messages chan<- tea.Msg) tea.Cmd { go func() { if err := runRunners(runners, messages); err != nil { messages <- TerminateWithError{Error: err} - return } - messages <- taskExecutionFinishedMsg{} }() return nil } @@ -161,11 +159,11 @@ func runRunners(runners Runners, messages chan<- tea.Msg) error { func runTaskAndChildren(runner *Runner, path []int, messages chan<- tea.Msg) error { runner.State = Running - messages <- taskStartedMsg{Path: clonePath(path)} + messages <- taskStartedMsg{Path: path} task := runner.Task callback := taskUpdateCallback(func(updated Task) { - messages <- taskUpdatedMsg{Path: clonePath(path), Task: updated} + messages <- taskUpdatedMsg{Path: path, Task: updated} }) taskUpdateCallbacks.Store(&task, callback) var err error @@ -178,21 +176,21 @@ func runTaskAndChildren(runner *Runner, path []int, messages chan<- tea.Msg) err if err != nil { runner.Task.Title = fmt.Sprintf("%s - %s", runner.Task.Title, err.Error()) runner.State = Failed - messages <- taskFailedMsg{Path: clonePath(path), Task: runner.Task, Error: err} + messages <- taskFailedMsg{Path: path, Task: runner.Task} return err } - messages <- taskUpdatedMsg{Path: clonePath(path), Task: runner.Task} + messages <- taskUpdatedMsg{Path: path, Task: runner.Task} for i := range runner.Children { if err := runTaskAndChildren(&runner.Children[i], pathWithIndex(path, i), messages); err != nil { runner.State = Failed - messages <- taskFailedMsg{Path: clonePath(path), Task: runner.Task, Error: err} + messages <- taskFailedMsg{Path: path, Task: runner.Task} return err } } runner.State = Completed - messages <- taskCompletedMsg{Path: clonePath(path), Task: runner.Task} + messages <- taskCompletedMsg{Path: path, Task: runner.Task} return nil } @@ -237,12 +235,6 @@ func pathWithIndex(path []int, index int) []int { return next } -func clonePath(path []int) []int { - cloned := make([]int, len(path)) - copy(cloned, path) - return cloned -} - func IsCI() bool { return os.Getenv("CI") != "" || os.Getenv("CONTINUOUS_INTEGRATION") != "" || From 041abac16342e775e25772e8ae0bbfa8122b9ab1 Mon Sep 17 00:00:00 2001 From: "Stanislav (Stas) Katkov" Date: Fri, 15 May 2026 02:43:19 +0200 Subject: [PATCH 5/5] Simplification refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Simplified runRunners error flow. - Deferred taskUpdateCallbacks.Delete(...) so callback cleanup is guaranteed. - Moved ANSI escape regex compilation out of Write. - Removed low-value comments. - Straightened applyProgress’s zero-total path. --- mvc.go | 3 --- taskin.go | 38 ++++++++++++++++++-------------------- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/mvc.go b/mvc.go index 29ad5cd..1951e1b 100644 --- a/mvc.go +++ b/mvc.go @@ -85,7 +85,6 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case spinner.TickMsg: var cmds []tea.Cmd - // Helper function to update spinners recursively var updateSpinners func(runner *Runner) []tea.Cmd updateSpinners = func(runner *Runner) []tea.Cmd { var spinnerCmds []tea.Cmd @@ -98,7 +97,6 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // Recursively update all children's spinners for i := range runner.Children { spinnerCmds = append(spinnerCmds, updateSpinners(&runner.Children[i])...) } @@ -199,7 +197,6 @@ func renderTask(runner Runner, indent string) string { view = indent + lipgloss.NewStyle().Render(status) + "\n" } - // Recursively render children if len(runner.Children) > 0 && (runner.State == Running || IsCI() || runner.Config.DisableUI) { for _, child := range runner.Children { view += renderTask(child, indent+" ") diff --git a/taskin.go b/taskin.go index 20d1073..273aead 100644 --- a/taskin.go +++ b/taskin.go @@ -18,13 +18,15 @@ type taskUpdateCallback func(Task) var taskUpdateCallbacks sync.Map +var ansiEscapeCodePattern = regexp.MustCompile(` *\x1b\[[0-?]*[ -/]*[@-~]`) + func NewRunner(task Task, cfg Config) Runner { var spinr *spinner.Model if !IsCI() && !cfg.DisableUI { - spinnerModel := spinner.New(spinner.WithSpinner(cfg.Spinner)) // Initialize with a spinner model - spinnerModel.Style = lipgloss.NewStyle().Foreground(cfg.Colors.Spinner) // Styling spinner + spinnerModel := spinner.New(spinner.WithSpinner(cfg.Spinner)) + spinnerModel.Style = lipgloss.NewStyle().Foreground(cfg.Colors.Spinner) spinr = &spinnerModel if task.ShowProgress.Total != 0 { @@ -68,10 +70,11 @@ func (task *Task) applyProgress(taskProgress TaskProgress) { if !task.Bar.IsAnimating() { task.Bar = progress.New(task.Config.ProgressOptions...) } - if taskProgress.Total != 0 { // Check if TaskProgress is set - percent := float64(taskProgress.Current) / float64(taskProgress.Total) - task.Bar.SetPercent(percent) + if taskProgress.Total == 0 { + return } + percent := float64(taskProgress.Current) / float64(taskProgress.Total) + task.Bar.SetPercent(percent) } func (task *Task) notifyUpdate() { @@ -87,11 +90,7 @@ type ansiEscapeCodeFilter struct { } func (f *ansiEscapeCodeFilter) Write(p []byte) (n int, err error) { - // Corrected regular expression to match ANSI escape codes - re := regexp.MustCompile(` *\x1b\[[0-?]*[ -/]*[@-~]`) - // Remove the escape codes from the input - p = re.ReplaceAll(p, []byte{}) - // Write the filtered input to the original writer + p = ansiEscapeCodePattern.ReplaceAll(p, []byte{}) return f.writer.Write(p) } @@ -99,7 +98,6 @@ func (r *Runners) Run() error { m := &Model{Runners: cloneRunners(*r), Shutdown: false, ShutdownError: nil, taskMessages: make(chan tea.Msg, 64)} var out io.Writer = os.Stdout - // Check if we need to disable UI features or are in CI mode if IsCI() || (len(*r) > 0 && (*r)[0].Config.DisableUI) { out = &ansiEscapeCodeFilter{writer: out} } @@ -144,14 +142,13 @@ func runRunners(runners Runners, messages chan<- tea.Msg) error { var firstErr error for i := range runners { err := runTaskAndChildren(&runners[i], pathWithIndex(nil, i), messages) - if err == nil { - continue - } - if firstErr == nil { - firstErr = err - } - if runners[i].Config.Options.ExitOnFailure { - return firstErr + if err != nil { + if firstErr == nil { + firstErr = err + } + if runners[i].Config.Options.ExitOnFailure { + return firstErr + } } } return firstErr @@ -166,11 +163,12 @@ func runTaskAndChildren(runner *Runner, path []int, messages chan<- tea.Msg) err messages <- taskUpdatedMsg{Path: path, Task: updated} }) taskUpdateCallbacks.Store(&task, callback) + defer taskUpdateCallbacks.Delete(&task) + var err error if task.Task != nil { err = task.Task(&task) } - taskUpdateCallbacks.Delete(&task) runner.Task = snapshotTask(task) if err != nil {