summaryrefslogtreecommitdiff
path: root/internal/debug
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 /internal/debug
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
Diffstat (limited to 'internal/debug')
-rw-r--r--internal/debug/signals.go176
-rw-r--r--internal/debug/signals_test.go83
-rw-r--r--internal/debug/signals_windows.go21
3 files changed, 280 insertions, 0 deletions
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")
+}