diff options
| -rw-r--r-- | AGENTS.md | 13 | ||||
| -rw-r--r-- | Magefile.go | 53 | ||||
| -rw-r--r-- | README.md | 87 | ||||
| -rw-r--r-- | cmd/tasksamurai/main.go | 6 | ||||
| -rw-r--r-- | go.mod | 1 | ||||
| -rw-r--r-- | go.sum | 2 | ||||
| -rw-r--r-- | internal/debug/signals.go | 176 | ||||
| -rw-r--r-- | internal/debug/signals_test.go | 83 | ||||
| -rw-r--r-- | internal/debug/signals_windows.go | 21 | ||||
| -rw-r--r-- | internal/version.go | 2 |
10 files changed, 436 insertions, 8 deletions
@@ -10,16 +10,21 @@ TaskSamurai is a fast Terminal User Interface (TUI) for Taskwarrior written in G ```bash # Build the application -go-task +mage build +# or simply +mage # Run the application -go-task run +mage run # Run all tests -go-task test +mage test # Install to $GOPATH/bin -go-task install +mage install + +# Clean build artifacts +mage clean # Run with debug logging ./tasksamurai --debug-log debug.log diff --git a/Magefile.go b/Magefile.go new file mode 100644 index 0000000..b4b5ecc --- /dev/null +++ b/Magefile.go @@ -0,0 +1,53 @@ +//go:build mage +// +build mage + +package main + +import ( + "fmt" + "os" + + "github.com/magefile/mage/sh" +) + +// Default target builds the tasksamurai binary +func Default() error { + return Build() +} + +// Build compiles the tasksamurai binary +func Build() error { + fmt.Println("Building tasksamurai...") + return sh.Run("go", "build", "-o", "tasksamurai", "./cmd/tasksamurai") +} + +// Run starts tasksamurai with any provided arguments +func Run() error { + fmt.Println("Running tasksamurai...") + args := append([]string{"run", "./cmd/tasksamurai"}, os.Args[1:]...) + return sh.Run("go", args...) +} + +// Test runs all tests +func Test() error { + fmt.Println("Running tests...") + return sh.Run("go", "test", "./...") +} + +// Install installs tasksamurai to $GOPATH/bin +func Install() error { + fmt.Println("Installing tasksamurai...") + return sh.Run("go", "install", "./cmd/tasksamurai") +} + +// Clean removes the built binary +func Clean() error { + fmt.Println("Cleaning...") + if err := sh.Rm("tasksamurai"); err != nil { + // Ignore error if file doesn't exist + if !os.IsNotExist(err) { + return err + } + } + return nil +} @@ -35,10 +35,10 @@ go install codeberg.org/snonux/tasksamurai/cmd/tasksamurai@latest Alternatively, clone this repository and run: ```bash -go-task install +mage install ``` -The second method requires [go-task](https://taskfile.dev/) to be installed. +The second method requires [mage](https://magefile.org/) to be installed. ### Usage @@ -58,4 +58,85 @@ tasksamurai -- -excludetag +includetag ### Flags -- `--disco`: start Task Samurai in disco mode, changing the theme every time a task is modified. +- `--browser-cmd <command>`: command used to open URLs (default: firefox on Linux, open on macOS) +- `--debug-log <path>`: path to debug log file for Taskwarrior commands +- `--debug-dir <directory>`: directory for runtime debug output (goroutine dumps, profiles) +- `--disco`: start Task Samurai in disco mode, changing the theme every time a task is modified + +## Debugging + +If Task Samurai appears to hang or freeze, you can capture runtime diagnostics using signal handlers to help diagnose the issue. + +### Signal Handlers (Unix/Linux/macOS only) + +Task Samurai supports two debugging signals: + +#### SIGUSR1 - Quick Goroutine Dump + +Captures all goroutine stacks to a timestamped text file for quick inspection: + +```bash +# Find the Task Samurai process ID +ps aux | grep tasksamurai + +# Send signal to dump goroutines +kill -SIGUSR1 <pid> +``` + +This creates a file like `tasksamurai-goroutines-20260204-143022.txt` showing what each goroutine is doing. + +#### SIGUSR2 - Full Profile Dump + +Captures comprehensive profiling data for deeper analysis: + +```bash +# Send signal to dump full profiles +kill -SIGUSR2 <pid> +``` + +This creates multiple files: +- `tasksamurai-TIMESTAMP-goroutines.txt` - Goroutine stacks (text) +- `tasksamurai-TIMESTAMP-heap.pprof` - Memory allocations +- `tasksamurai-TIMESTAMP-cpu.pprof` - CPU profile (5 second sample) +- `tasksamurai-TIMESTAMP-block.pprof` - Lock contention events + +### Analyzing Profiles + +Use Go's pprof tool to analyze the binary profile files: + +```bash +# Interactive analysis +go tool pprof tasksamurai-TIMESTAMP-heap.pprof + +# Generate visualization (requires graphviz) +go tool pprof -web tasksamurai-TIMESTAMP-cpu.pprof + +# Top functions by CPU usage +go tool pprof -top tasksamurai-TIMESTAMP-cpu.pprof +``` + +### Specifying Output Location + +By default, debug files are written to the current working directory. Use the `--debug-dir` flag to specify a different location: + +```bash +tasksamurai --debug-dir=/tmp/tasksamurai-debug +``` + +### Example Debugging Workflow + +When Task Samurai hangs: + +1. **Keep the hung process running** - Don't kill it yet! +2. Find the process ID: `pgrep tasksamurai` or `ps aux | grep tasksamurai` +3. Dump goroutines: `kill -SIGUSR1 <pid>` +4. Open the generated file to see what goroutines are blocked +5. If needed, dump full profiles: `kill -SIGUSR2 <pid>` +6. Analyze with pprof to identify the bottleneck + +Common issues revealed by goroutine dumps: +- External `task` command hanging (stuck in syscall) +- Waiting for terminal input (blocked on I/O) +- External editor not responding + +**Note:** Signal handlers are not available on Windows. Consider using `GODEBUG` environment variables or running under a debugger instead. diff --git a/cmd/tasksamurai/main.go b/cmd/tasksamurai/main.go index b964f52..96c8be8 100644 --- a/cmd/tasksamurai/main.go +++ b/cmd/tasksamurai/main.go @@ -7,6 +7,7 @@ import ( "runtime" + "codeberg.org/snonux/tasksamurai/internal/debug" "codeberg.org/snonux/tasksamurai/internal/task" "codeberg.org/snonux/tasksamurai/internal/ui" @@ -21,6 +22,7 @@ func main() { } debugLog := flag.String("debug-log", "", "path to debug log file") + debugDir := flag.String("debug-dir", "", "directory for runtime debug output (goroutine dumps, profiles)") browserCmd := flag.String("browser-cmd", browserCmdDefault, "command used to open URLs") disco := flag.Bool("disco", false, "enable disco mode") flag.Parse() @@ -30,6 +32,10 @@ func main() { os.Exit(1) } + // Set up runtime debugging signal handlers + debug.SetDebugDir(*debugDir) + debug.InitSignalHandlers() + m, err := ui.New(flag.Args(), *browserCmd) if err != nil { fmt.Fprintln(os.Stderr, "failed to load tasks:", err) @@ -18,6 +18,7 @@ require ( github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/magefile/mage v1.15.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect @@ -26,6 +26,8 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= +github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= diff --git a/internal/debug/signals.go b/internal/debug/signals.go new file mode 100644 index 0000000..89e53ea --- /dev/null +++ b/internal/debug/signals.go @@ -0,0 +1,176 @@ +// +build !windows + +package debug + +import ( + "fmt" + "os" + "os/signal" + "path/filepath" + "runtime" + "runtime/pprof" + "strings" + "sync/atomic" + "syscall" + "time" +) + +var ( + // debugDir is the directory where debug output files are written + debugDir string + // dumping prevents concurrent dump attempts + dumping int32 +) + +// SetDebugDir sets the directory where debug output files will be written. +// If empty, the current working directory is used. +func SetDebugDir(dir string) { + debugDir = dir +} + +// InitSignalHandlers sets up signal handlers for runtime diagnostics. +// SIGUSR1: Dump goroutine stacks +// SIGUSR2: Dump full runtime profiles (goroutines, heap, cpu, block) +func InitSignalHandlers() { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGUSR1, syscall.SIGUSR2) + + go func() { + for sig := range sigChan { + switch sig { + case syscall.SIGUSR1: + dumpGoroutines() + case syscall.SIGUSR2: + dumpFullProfile() + } + } + }() +} + +// dumpGoroutines writes all goroutine stacks to a file +func dumpGoroutines() { + if !atomic.CompareAndSwapInt32(&dumping, 0, 1) { + fmt.Fprintln(os.Stderr, "debug: dump already in progress, skipping") + return + } + defer atomic.StoreInt32(&dumping, 0) + + timestamp := time.Now().Format("20060102-150405") + filename := fmt.Sprintf("tasksamurai-goroutines-%s.txt", timestamp) + if debugDir != "" { + filename = filepath.Join(debugDir, filename) + } + + f, err := os.Create(filename) + if err != nil { + fmt.Fprintf(os.Stderr, "debug: failed to create goroutine dump file: %v\n", err) + return + } + defer f.Close() + + // Write header + fmt.Fprintf(f, "TaskSamurai Goroutine Dump\n") + fmt.Fprintf(f, "Timestamp: %s\n", time.Now().Format(time.RFC3339)) + fmt.Fprintf(f, "NumGoroutine: %d\n", runtime.NumGoroutine()) + fmt.Fprintf(f, "NumCPU: %d\n", runtime.NumCPU()) + fmt.Fprintf(f, "\n%s\n\n", strings.Repeat("=", 80)) + + // Get stack traces + buf := make([]byte, 1024*1024) // 1MB buffer + stackLen := runtime.Stack(buf, true) + f.Write(buf[:stackLen]) + + fmt.Fprintf(os.Stderr, "debug: goroutine stacks written to %s\n", filename) +} + +// dumpFullProfile writes comprehensive runtime profiles to files +func dumpFullProfile() { + if !atomic.CompareAndSwapInt32(&dumping, 0, 1) { + fmt.Fprintln(os.Stderr, "debug: dump already in progress, skipping") + return + } + defer atomic.StoreInt32(&dumping, 0) + + timestamp := time.Now().Format("20060102-150405") + prefix := fmt.Sprintf("tasksamurai-%s", timestamp) + if debugDir != "" { + prefix = filepath.Join(debugDir, fmt.Sprintf("tasksamurai-%s", timestamp)) + } + + fmt.Fprintf(os.Stderr, "debug: starting full profile dump...\n") + + // Dump goroutines (text format) + goroutineFile := prefix + "-goroutines.txt" + if err := writeProfile(goroutineFile, "goroutine", 1); err != nil { + fmt.Fprintf(os.Stderr, "debug: failed to write goroutine profile: %v\n", err) + } else { + fmt.Fprintf(os.Stderr, "debug: goroutine profile written to %s\n", goroutineFile) + } + + // Dump heap profile + heapFile := prefix + "-heap.pprof" + if err := writeProfile(heapFile, "heap", 0); err != nil { + fmt.Fprintf(os.Stderr, "debug: failed to write heap profile: %v\n", err) + } else { + fmt.Fprintf(os.Stderr, "debug: heap profile written to %s\n", heapFile) + } + + // Dump block profile + blockFile := prefix + "-block.pprof" + runtime.SetBlockProfileRate(1) + if err := writeProfile(blockFile, "block", 0); err != nil { + fmt.Fprintf(os.Stderr, "debug: failed to write block profile: %v\n", err) + } else { + fmt.Fprintf(os.Stderr, "debug: block profile written to %s\n", blockFile) + } + + // Dump CPU profile (5 second sample) + cpuFile := prefix + "-cpu.pprof" + if err := writeCPUProfile(cpuFile, 5*time.Second); err != nil { + fmt.Fprintf(os.Stderr, "debug: failed to write CPU profile: %v\n", err) + } else { + fmt.Fprintf(os.Stderr, "debug: CPU profile written to %s\n", cpuFile) + } + + fmt.Fprintf(os.Stderr, "debug: full profile dump complete\n") +} + +// writeProfile writes a pprof profile to a file +func writeProfile(filename, profileName string, debug int) error { + f, err := os.Create(filename) + if err != nil { + return err + } + defer f.Close() + + // Add header for text format + if debug > 0 { + fmt.Fprintf(f, "TaskSamurai %s Profile\n", profileName) + fmt.Fprintf(f, "Timestamp: %s\n", time.Now().Format(time.RFC3339)) + fmt.Fprintf(f, "\n%s\n\n", strings.Repeat("=", 80)) + } + + profile := pprof.Lookup(profileName) + if profile == nil { + return fmt.Errorf("profile %s not found", profileName) + } + + return profile.WriteTo(f, debug) +} + +// writeCPUProfile samples CPU usage and writes the profile +func writeCPUProfile(filename string, duration time.Duration) error { + f, err := os.Create(filename) + if err != nil { + return err + } + defer f.Close() + + if err := pprof.StartCPUProfile(f); err != nil { + return err + } + + time.Sleep(duration) + pprof.StopCPUProfile() + return nil +} diff --git a/internal/debug/signals_test.go b/internal/debug/signals_test.go new file mode 100644 index 0000000..d7b0d2d --- /dev/null +++ b/internal/debug/signals_test.go @@ -0,0 +1,83 @@ +// +build !windows + +package debug + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSetDebugDir(t *testing.T) { + testDir := "/tmp/test-debug" + SetDebugDir(testDir) + if debugDir != testDir { + t.Errorf("SetDebugDir failed: expected %s, got %s", testDir, debugDir) + } +} + +func TestInitSignalHandlers(t *testing.T) { + // This test just verifies InitSignalHandlers doesn't panic + // We can't easily test actual signal handling without more complex setup + InitSignalHandlers() +} + +func TestDumpGoroutines(t *testing.T) { + // Create a temporary directory for testing + tmpDir := t.TempDir() + SetDebugDir(tmpDir) + + // Call dumpGoroutines + dumpGoroutines() + + // Check that a file was created + files, err := filepath.Glob(filepath.Join(tmpDir, "tasksamurai-goroutines-*.txt")) + if err != nil { + t.Fatalf("failed to glob for goroutine files: %v", err) + } + + if len(files) == 0 { + t.Fatal("no goroutine dump file was created") + } + + // Verify the file is not empty + info, err := os.Stat(files[0]) + if err != nil { + t.Fatalf("failed to stat goroutine dump file: %v", err) + } + + if info.Size() == 0 { + t.Error("goroutine dump file is empty") + } + + t.Logf("Goroutine dump created: %s (%d bytes)", files[0], info.Size()) +} + +func TestWriteProfile(t *testing.T) { + tmpDir := t.TempDir() + + // Test goroutine profile (text format) + goroutineFile := filepath.Join(tmpDir, "test-goroutine.txt") + if err := writeProfile(goroutineFile, "goroutine", 1); err != nil { + t.Errorf("failed to write goroutine profile: %v", err) + } + + // Test heap profile (binary format) + heapFile := filepath.Join(tmpDir, "test-heap.pprof") + if err := writeProfile(heapFile, "heap", 0); err != nil { + t.Errorf("failed to write heap profile: %v", err) + } + + // Verify files exist and are not empty + for _, file := range []string{goroutineFile, heapFile} { + info, err := os.Stat(file) + if err != nil { + t.Errorf("profile file does not exist: %s", file) + continue + } + if info.Size() == 0 { + t.Errorf("profile file is empty: %s", file) + } + t.Logf("Profile created: %s (%d bytes)", file, info.Size()) + } +} diff --git a/internal/debug/signals_windows.go b/internal/debug/signals_windows.go new file mode 100644 index 0000000..89030f3 --- /dev/null +++ b/internal/debug/signals_windows.go @@ -0,0 +1,21 @@ +// +build windows + +package debug + +import ( + "fmt" + "os" +) + +// SetDebugDir sets the directory where debug output files will be written. +// On Windows, signal handlers are not supported, so this is a no-op. +func SetDebugDir(dir string) { + // No-op on Windows +} + +// InitSignalHandlers sets up signal handlers for runtime diagnostics. +// On Windows, SIGUSR1 and SIGUSR2 are not available, so this prints a warning. +func InitSignalHandlers() { + fmt.Fprintln(os.Stderr, "debug: signal handlers not supported on Windows") + fmt.Fprintln(os.Stderr, "debug: consider using GODEBUG environment variable or pprof HTTP endpoint") +} diff --git a/internal/version.go b/internal/version.go index 4bb79f7..4829ec5 100644 --- a/internal/version.go +++ b/internal/version.go @@ -1,4 +1,4 @@ package internal // Version is the current version of Task Samurai. -const Version = "0.9.3" +const Version = "0.10.0" |
