diff --git a/client/ui/main.go b/client/ui/main.go index 9516810c1..2ebdb4cf2 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -80,11 +80,6 @@ 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 @@ -100,6 +95,10 @@ func main() { } }) + // Debug patch, not for release: dumps heap/goroutine profiles and the + // process tree to /tmp/nbgui for the memory consumption investigation. + startMemProfiler(app) + profiles := services.NewProfiles(conn) // updater.Holder owns the typed update State; DaemonFeed feeds it and the // Update service is a thin Wails-bound facade over it plus the install RPCs. diff --git a/client/ui/memprof.go b/client/ui/memprof.go index d071be2e9..a27d095c8 100644 --- a/client/ui/memprof.go +++ b/client/ui/memprof.go @@ -3,19 +3,28 @@ package main import ( + "bufio" "fmt" "os" "path/filepath" "runtime" "runtime/pprof" + "strconv" "strings" "time" "github.com/shirou/gopsutil/v4/process" log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" ) -const memProfDelay = 5 * time.Minute +// memProfOffsets are the snapshot times measured from application startup. +var memProfOffsets = []time.Duration{0, 2 * time.Minute, 5 * time.Minute} + +// memProfMaxDepth bounds the child walk so a cycle in the reported parent links +// cannot spin forever. +const memProfMaxDepth = 4 type memProfileSpec struct { profile string @@ -32,18 +41,24 @@ var memProfileSpecs = []memProfileSpec{ 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) +// startMemProfiler dumps a profile snapshot at every memProfOffsets mark, each +// into its own timestamped directory under memProfBaseDir. The first runs once +// the application is up so the window inventory sees the eagerly created +// windows. Every failure is logged and never stops the GUI. +func startMemProfiler(app *application.App) { + log.Infof("memory profiler enabled, writing to %s (snapshots at %v after startup)", memProfBaseDir(), memProfOffsets) - go func() { - writeMemProfile() - time.Sleep(memProfDelay) - writeMemProfile() - }() + app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { + go func() { + started := time.Now() + for _, offset := range memProfOffsets { + if wait := time.Until(started.Add(offset)); wait > 0 { + time.Sleep(wait) + } + writeMemProfile(app) + } + }() + }) } // memProfBaseDir returns the directory holding the snapshot directories. @@ -55,8 +70,8 @@ func memProfBaseDir() string { } // writeMemProfile creates a - directory and fills it with the -// runtime profiles and the memory statistics summary. -func writeMemProfile() { +// runtime profiles, the memory statistics summary and the process tree. +func writeMemProfile(app *application.App) { 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 { @@ -68,10 +83,14 @@ func writeMemProfile() { // one to keep inuse_space from counting garbage that is already unreachable. runtime.GC() - if err := writeMemStats(filepath.Join(dir, "memstats.txt")); err != nil { + if err := writeMemStats(filepath.Join(dir, "memstats.txt"), app); err != nil { log.Warnf("write memory statistics: %v", err) } + if err := writeProcTree(filepath.Join(dir, "proctree.txt")); err != nil { + log.Warnf("write process tree: %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) @@ -108,7 +127,7 @@ func writeMemProfileFile(spec memProfileSpec, path string) error { // 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 { +func writeMemStats(path string, app *application.App) error { var m runtime.MemStats runtime.ReadMemStats(&m) @@ -143,6 +162,9 @@ func writeMemStats(path string) error { 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)) + fmt.Fprintf(&b, "\n") + + writeWindowInventory(&b, app) if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { return fmt.Errorf("write %s: %w", path, err) @@ -150,6 +172,121 @@ func writeMemStats(path string) error { return nil } +// writeWindowInventory lists the live Wails windows. A window that exists holds +// a webview process even while hidden, so this tells apart a leaked window (the +// count grows) from windows whose content grew (the count stays put). +func writeWindowInventory(b *strings.Builder, app *application.App) { + windows := app.Window.GetAll() + fmt.Fprintf(b, "windows: %d\n", len(windows)) + for _, w := range windows { + visible := "unknown" + if ww, ok := w.(*application.WebviewWindow); ok { + visible = strconv.FormatBool(ww.IsVisible()) + } + fmt.Fprintf(b, " id=%-3d name=%-20q visible=%-7s minimised=%-5t focused=%t\n", + w.ID(), w.Name(), visible, w.IsMinimised(), w.IsFocused()) + } +} + +// writeProcTree dumps this process and its descendants with their memory +// footprint. The webview runs in child processes whose memory the Go runtime +// profiles cannot see, so this is what attributes a footprint to a component. +func writeProcTree(path string) error { + self, err := process.NewProcess(int32(os.Getpid())) + if err != nil { + return fmt.Errorf("open own process: %w", err) + } + + var b strings.Builder + fmt.Fprintf(&b, "time: %s\n", time.Now().Format(time.RFC3339)) + fmt.Fprintf(&b, "uptime: %s\n\n", time.Since(memProfStart).Round(time.Second)) + fmt.Fprintf(&b, "%-8s %-8s %-28s %12s %12s %12s %12s\n", "PID", "PPID", "NAME", "RSS", "VMS", "PSS", "PRIV_DIRTY") + + var totalRSS, totalPSS, totalPrivate uint64 + walkProcTree(&b, self, 0, &totalRSS, &totalPSS, &totalPrivate) + + fmt.Fprintf(&b, "\n%-8s %-8s %-28s %12s %12s %12s %12s\n", "", "", "TOTAL", + formatKB(totalRSS), "", formatKB(totalPSS), formatKB(totalPrivate)) + fmt.Fprintf(&b, "\nPSS and PRIV_DIRTY come from /proc//smaps_rollup and are Linux only.\n") + + if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + return nil +} + +// walkProcTree appends one line per process, depth-first, accumulating totals. +func walkProcTree(b *strings.Builder, p *process.Process, depth int, totalRSS, totalPSS, totalPrivate *uint64) { + name, err := p.Name() + if err != nil { + name = "unknown" + } + + var rss, vms uint64 + if info, err := p.MemoryInfo(); err == nil { + rss, vms = info.RSS, info.VMS + } + + pss, private := smapsRollup(p.Pid) + *totalRSS += rss + *totalPSS += pss + *totalPrivate += private + + ppid, err := p.Ppid() + if err != nil { + ppid = -1 + } + + fmt.Fprintf(b, "%-8d %-8d %-28s %12s %12s %12s %12s\n", p.Pid, ppid, + strings.Repeat(" ", depth)+name, formatKB(rss), formatKB(vms), formatKB(pss), formatKB(private)) + + if depth >= memProfMaxDepth { + return + } + + children, err := p.Children() + if err != nil { + return + } + for _, child := range children { + walkProcTree(b, child, depth+1, totalRSS, totalPSS, totalPrivate) + } +} + +// smapsRollup returns the proportional set size and private dirty bytes of pid, +// both zero on platforms without /proc. +func smapsRollup(pid int32) (uint64, uint64) { + f, err := os.Open(fmt.Sprintf("/proc/%d/smaps_rollup", pid)) + if err != nil { + return 0, 0 + } + defer func() { + if err := f.Close(); err != nil { + log.Debugf("close smaps_rollup for %d: %v", pid, err) + } + }() + + var pss, private uint64 + scanner := bufio.NewScanner(f) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 2 { + continue + } + kb, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + continue + } + switch fields[0] { + case "Pss:": + pss = kb * 1024 + case "Private_Dirty:": + private = kb * 1024 + } + } + return pss, private +} + // processMemory returns the formatted resident and virtual size of this process. func processMemory() (string, string) { p, err := process.NewProcess(int32(os.Getpid())) @@ -171,3 +308,12 @@ func processMemory() (string, string) { func formatMemBytes(n uint64) string { return fmt.Sprintf("%8.1f MB (%d bytes)", float64(n)/(1024*1024), n) } + +// formatKB renders a byte count as megabytes for the process tree columns, and +// a dash when the platform did not report the value. +func formatKB(n uint64) string { + if n == 0 { + return "-" + } + return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024)) +}