summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-04 09:11:30 +0200
committerPaul Buetow <paul@buetow.org>2026-02-04 09:11:30 +0200
commit288aa24e8bc4f22cb7de5d60789e1d6ec79f7395 (patch)
treebe644130fbffaf946e3122ffdf89dc203447124a
parent6d8953d52efa6037b679d7a722b060c687843f3a (diff)
add runtime debugging signals and convert to magev0.10.0
- Add SIGUSR1 handler for goroutine stack dumps when app hangs - Add SIGUSR2 handler for full profiling (heap, cpu, block) - Add --debug-dir flag for configurable debug output location - Convert build system from go-task to mage - Update documentation with debugging workflow - Bump version to 0.10.0
-rw-r--r--AGENTS.md13
-rw-r--r--Magefile.go53
-rw-r--r--README.md87
-rw-r--r--cmd/tasksamurai/main.go6
-rw-r--r--go.mod1
-rw-r--r--go.sum2
-rw-r--r--internal/debug/signals.go176
-rw-r--r--internal/debug/signals_test.go83
-rw-r--r--internal/debug/signals_windows.go21
-rw-r--r--internal/version.go2
10 files changed, 436 insertions, 8 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 3514455..5614e44 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
+}
diff --git a/README.md b/README.md
index e6c9e6d..94359f8 100644
--- a/README.md
+++ b/README.md
@@ -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)
diff --git a/go.mod b/go.mod
index 28a9a26..49cbc96 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 5375c80..29a5ba6 100644
--- a/go.sum
+++ b/go.sum
@@ -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"