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/signals.go | |
| 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/signals.go')
| -rw-r--r-- | internal/debug/signals.go | 176 |
1 files changed, 176 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 +} |
