From 079aee8d63de8e0a7dee6dafc6615e75fc56554b Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Fri, 7 Aug 2026 12:47:18 +0200 Subject: [PATCH] [client] Debug: dump GUI memory profiles to /tmp/nbgui Temporary debug patch for the GUI memory consumption investigation. Writes two snapshots - one at startup, one after 5 minutes - each into its own /tmp/nbgui/- directory containing the heap, goroutine and threadcreate profiles plus a memstats.txt summary. --- client/ui/main.go | 5 ++ client/ui/memprof.go | 173 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 client/ui/memprof.go diff --git a/client/ui/main.go b/client/ui/main.go index 5f740f5ec..9516810c1 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -80,6 +80,11 @@ func init() { func main() { daemonAddr, userSetLogFile := parseFlagsAndInitLog() + + // Debug patch, not for release: dumps heap/goroutine profiles to + // /tmp/nbgui for the memory consumption investigation. + startMemProfiler() + conn := NewConn(daemonAddr) // Without --log-file, the GUI manages a gui-client.log that follows the diff --git a/client/ui/memprof.go b/client/ui/memprof.go new file mode 100644 index 000000000..d071be2e9 --- /dev/null +++ b/client/ui/memprof.go @@ -0,0 +1,173 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "runtime/pprof" + "strings" + "time" + + "github.com/shirou/gopsutil/v4/process" + log "github.com/sirupsen/logrus" +) + +const memProfDelay = 5 * time.Minute + +type memProfileSpec struct { + profile string + file string + debug int +} + +var memProfileSpecs = []memProfileSpec{ + {profile: "heap", file: "heap.pprof", debug: 0}, + {profile: "heap", file: "heap.txt", debug: 1}, + {profile: "goroutine", file: "goroutine.txt", debug: 1}, + {profile: "threadcreate", file: "threadcreate.txt", debug: 1}, +} + +var memProfStart = time.Now() + +// startMemProfiler dumps two profile snapshots for memory analysis: one at +// startup and one after memProfDelay, each into its own timestamped directory +// under memProfBaseDir. Every failure is logged and never stops the GUI. +func startMemProfiler() { + base := memProfBaseDir() + log.Infof("memory profiler enabled, writing to %s (snapshots at startup and after %s)", base, memProfDelay) + + go func() { + writeMemProfile() + time.Sleep(memProfDelay) + writeMemProfile() + }() +} + +// memProfBaseDir returns the directory holding the snapshot directories. +func memProfBaseDir() string { + if runtime.GOOS == "windows" { + return filepath.Join(os.TempDir(), "nbgui") + } + return "/tmp/nbgui" +} + +// writeMemProfile creates a - directory and fills it with the +// runtime profiles and the memory statistics summary. +func writeMemProfile() { + name := fmt.Sprintf("%s-%d", time.Now().Format("20060102-150405"), os.Getpid()) + dir := filepath.Join(memProfBaseDir(), name) + if err := os.MkdirAll(dir, 0o755); err != nil { + log.Warnf("create memory profile dir %s: %v", dir, err) + return + } + + // The heap profile reports live objects as of the last collection, so force + // one to keep inuse_space from counting garbage that is already unreachable. + runtime.GC() + + if err := writeMemStats(filepath.Join(dir, "memstats.txt")); err != nil { + log.Warnf("write memory statistics: %v", err) + } + + for _, spec := range memProfileSpecs { + if err := writeMemProfileFile(spec, filepath.Join(dir, spec.file)); err != nil { + log.Warnf("write %s profile: %v", spec.profile, err) + } + } + + log.Infof("memory profile written to %s", dir) +} + +// writeMemProfileFile writes a single runtime profile to path. +func writeMemProfileFile(spec memProfileSpec, path string) error { + p := pprof.Lookup(spec.profile) + if p == nil { + return fmt.Errorf("unknown profile %q", spec.profile) + } + + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("create %s: %w", path, err) + } + defer func() { + if err := f.Close(); err != nil { + log.Debugf("close %s: %v", path, err) + } + }() + + if err := p.WriteTo(f, spec.debug); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + return nil +} + +// writeMemStats dumps the runtime memory statistics next to the process +// resident set size. A resident set much larger than Sys means the memory sits +// outside the Go heap (webview, GTK, other cgo allocations), where the pprof +// profiles cannot see it. +func writeMemStats(path string) error { + var m runtime.MemStats + runtime.ReadMemStats(&m) + + var b strings.Builder + fmt.Fprintf(&b, "time: %s\n", time.Now().Format(time.RFC3339)) + fmt.Fprintf(&b, "uptime: %s\n", time.Since(memProfStart).Round(time.Second)) + fmt.Fprintf(&b, "pid: %d\n", os.Getpid()) + fmt.Fprintf(&b, "\n") + + rss, vms := processMemory() + fmt.Fprintf(&b, "process_rss: %s\n", rss) + fmt.Fprintf(&b, "process_vms: %s\n", vms) + fmt.Fprintf(&b, "\n") + + fmt.Fprintf(&b, "sys: %s\n", formatMemBytes(m.Sys)) + fmt.Fprintf(&b, "heap_alloc: %s\n", formatMemBytes(m.HeapAlloc)) + fmt.Fprintf(&b, "heap_sys: %s\n", formatMemBytes(m.HeapSys)) + fmt.Fprintf(&b, "heap_inuse: %s\n", formatMemBytes(m.HeapInuse)) + fmt.Fprintf(&b, "heap_idle: %s\n", formatMemBytes(m.HeapIdle)) + fmt.Fprintf(&b, "heap_released: %s\n", formatMemBytes(m.HeapReleased)) + fmt.Fprintf(&b, "heap_objects: %d\n", m.HeapObjects) + fmt.Fprintf(&b, "stack_inuse: %s\n", formatMemBytes(m.StackInuse)) + fmt.Fprintf(&b, "stack_sys: %s\n", formatMemBytes(m.StackSys)) + fmt.Fprintf(&b, "mspan_sys: %s\n", formatMemBytes(m.MSpanSys)) + fmt.Fprintf(&b, "mcache_sys: %s\n", formatMemBytes(m.MCacheSys)) + fmt.Fprintf(&b, "gc_sys: %s\n", formatMemBytes(m.GCSys)) + fmt.Fprintf(&b, "other_sys: %s\n", formatMemBytes(m.OtherSys)) + fmt.Fprintf(&b, "next_gc: %s\n", formatMemBytes(m.NextGC)) + fmt.Fprintf(&b, "num_gc: %d\n", m.NumGC) + fmt.Fprintf(&b, "\n") + + fmt.Fprintf(&b, "goroutines: %d\n", runtime.NumGoroutine()) + fmt.Fprintf(&b, "cgo_calls: %d\n", runtime.NumCgoCall()) + fmt.Fprintf(&b, "gomaxprocs: %d\n", runtime.GOMAXPROCS(0)) + + if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + return nil +} + +// processMemory returns the formatted resident and virtual size of this process. +func processMemory() (string, string) { + p, err := process.NewProcess(int32(os.Getpid())) + if err != nil { + unavailable := fmt.Sprintf("unavailable (%v)", err) + return unavailable, unavailable + } + + info, err := p.MemoryInfo() + if err != nil { + unavailable := fmt.Sprintf("unavailable (%v)", err) + return unavailable, unavailable + } + + return formatMemBytes(info.RSS), formatMemBytes(info.VMS) +} + +// formatMemBytes renders a byte count as megabytes with the raw value kept. +func formatMemBytes(n uint64) string { + return fmt.Sprintf("%8.1f MB (%d bytes)", float64(n)/(1024*1024), n) +}