diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-04 09:11:30 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-04 09:11:30 +0200 |
| commit | 288aa24e8bc4f22cb7de5d60789e1d6ec79f7395 (patch) | |
| tree | be644130fbffaf946e3122ffdf89dc203447124a /internal/debug | |
| parent | 6d8953d52efa6037b679d7a722b060c687843f3a (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.go | 176 | ||||
| -rw-r--r-- | internal/debug/signals_test.go | 83 | ||||
| -rw-r--r-- | internal/debug/signals_windows.go | 21 |
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") +} |
