wip: add synchronized output to prevent prompt interleaving

- Add SyncWriter to synchronize stdout/stderr writes with prompts
- Add promptMutex to serialize interactive prompts
- Keep rawStdout/rawStderr for BubbleTea to avoid deadlock
- Update test Taskfile with nested deps scenario
This commit is contained in:
Valentin Maerten
2025-12-13 12:28:10 +01:00
parent 419442bba6
commit 00f7788c35
6 changed files with 112 additions and 19 deletions

View File

@@ -57,6 +57,8 @@ type (
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
rawStdout io.Writer // unwrapped stdout for prompts
rawStderr io.Writer // unwrapped stderr for prompts
// Internal
Taskfile *ast.Taskfile
@@ -71,6 +73,7 @@ type (
fuzzyModel *fuzzy.Model
fuzzyModelOnce sync.Once
promptMutex sync.Mutex
concurrencySemaphore chan struct{}
taskCallCount map[string]*int32
mkdirMutexMap map[string]*sync.Mutex

View File

@@ -0,0 +1,28 @@
package output
import (
"io"
"sync"
)
// SyncWriter wraps an io.Writer with a mutex to synchronize writes.
// This is used to prevent output from interleaving with interactive prompts.
type SyncWriter struct {
w io.Writer
mu *sync.Mutex
}
// NewSyncWriter creates a new SyncWriter that uses the provided mutex.
func NewSyncWriter(w io.Writer, mu *sync.Mutex) *SyncWriter {
return &SyncWriter{
w: w,
mu: mu,
}
}
// Write implements io.Writer with synchronized access.
func (sw *SyncWriter) Write(p []byte) (n int, err error) {
sw.mu.Lock()
defer sw.mu.Unlock()
return sw.w.Write(p)
}

View File

@@ -27,7 +27,16 @@ func (e *Executor) promptForInteractiveVars(t *ast.Task, call *Call) (bool, erro
return false, nil
}
prompter := prompt.New()
// Lock to prevent multiple parallel prompts from interleaving
e.promptMutex.Lock()
defer e.promptMutex.Unlock()
// Use raw stderr for prompts to avoid deadlock with SyncWriter
prompter := &prompt.Prompter{
Stdin: e.Stdin,
Stdout: e.rawStdout,
Stderr: e.rawStderr,
}
var prompted bool
for _, requiredVar := range t.Requires.Vars {

View File

@@ -179,6 +179,17 @@ func (e *Executor) setupStdFiles() {
if e.Stderr == nil {
e.Stderr = os.Stderr
}
// Keep raw references for interactive prompts
e.rawStdout = e.Stdout
e.rawStderr = e.Stderr
// Wrap with synchronized writers when interactive mode is enabled
// to prevent output from interleaving with prompts
if e.Interactive {
e.Stdout = output.NewSyncWriter(e.Stdout, &e.promptMutex)
e.Stderr = output.NewSyncWriter(e.Stderr, &e.promptMutex)
}
}
func (e *Executor) setupLogger() {

1
testdata/interactive_vars/.taskrc.yml vendored Normal file
View File

@@ -0,0 +1 @@
interactive: true

View File

@@ -1,25 +1,66 @@
version: "3"
tasks:
simple:
requires:
vars:
- MY_VAR
main:
desc: Main task with nested deps
deps:
- dep-a
- dep-b
cmds:
- echo "{{.MY_VAR}}"
- echo "Main task done!"
with-enum:
requires:
vars:
- name: ENV
enum: [dev, staging, prod]
dep-a:
desc: Dependency A
deps:
- leaf-a1
- leaf-a2
cmds:
- echo "{{.ENV}}"
- echo "Dep A done"
multiple:
dep-b:
desc: Dependency B
deps:
- leaf-b1
- leaf-b2
cmds:
- echo "Dep B done"
leaf-a1:
desc: Leaf A1 with enum
requires:
vars:
- VAR1
- VAR2
- name: VAR_A1
enum:
- alpha
- beta
- gamma
cmds:
- echo "{{.VAR1}} {{.VAR2}}"
- echo "Leaf A1 {{.VAR_A1}}"
leaf-a2:
desc: Leaf A2 with text
requires:
vars:
- VAR_A2
cmds:
- echo "Leaf A2 {{.VAR_A2}}"
leaf-b1:
desc: Leaf B1 with enum
requires:
vars:
- name: VAR_B1
enum:
- one
- two
- three
cmds:
- echo "Leaf B1 {{.VAR_B1}}"
leaf-b2:
desc: Leaf B2 with text
requires:
vars:
- VAR_B2
cmds:
- echo "Leaf B2 {{.VAR_B2}}"