// force-quit-gui is a minimal, always-running process killer. It starts // minimized at login and is revealed by swap-watchdog when memory pressure // gets critical. Deliberately plain GTK3 (via gotk3) rather than anything // heavier, so it has the best chance of already being alive and responsive // exactly when the system is least able to start something new. package main import ( "bufio" "fmt" "log" "os" "path/filepath" "sort" "strconv" "strings" "syscall" "github.com/gotk3/gotk3/glib" "github.com/gotk3/gotk3/gtk" ) const refreshIntervalMs = 2000 type procInfo struct { pid int name string rssKB int64 swapKB int64 totalKB int64 } func readProcesses() []procInfo { entries, err := os.ReadDir("/proc") if err != nil { return nil } var procs []procInfo for _, e := range entries { pid, err := strconv.Atoi(e.Name()) if err != nil { continue } status, err := os.Open(filepath.Join("/proc", e.Name(), "status")) if err != nil { continue } var rss, swap int64 scanner := bufio.NewScanner(status) for scanner.Scan() { fields := strings.Fields(scanner.Text()) if len(fields) < 2 { continue } switch fields[0] { case "VmRSS:": rss, _ = strconv.ParseInt(fields[1], 10, 64) case "VmSwap:": swap, _ = strconv.ParseInt(fields[1], 10, 64) } } status.Close() if rss == 0 && swap == 0 { continue } name := e.Name() if commBytes, err := os.ReadFile(filepath.Join("/proc", e.Name(), "comm")); err == nil { name = strings.TrimSpace(string(commBytes)) } procs = append(procs, procInfo{pid: pid, name: name, rssKB: rss, swapKB: swap, totalKB: rss + swap}) } sort.Slice(procs, func(i, j int) bool { return procs[i].totalKB > procs[j].totalKB }) return procs } type memStatus struct { ramUsedKB, ramTotalKB int64 swapUsedKB, swapTotalKB int64 } // readMemStatus reports system-wide RAM and swap usage for the accounting // line under the warning banner, as distinct from readProcesses' per-process // breakdown. func readMemStatus() memStatus { f, err := os.Open("/proc/meminfo") if err != nil { return memStatus{} } defer f.Close() var memTotal, memAvailable, swapTotal, swapFree int64 scanner := bufio.NewScanner(f) for scanner.Scan() { fields := strings.Fields(scanner.Text()) if len(fields) < 2 { continue } val, _ := strconv.ParseInt(fields[1], 10, 64) switch fields[0] { case "MemTotal:": memTotal = val case "MemAvailable:": memAvailable = val case "SwapTotal:": swapTotal = val case "SwapFree:": swapFree = val } } return memStatus{ ramUsedKB: memTotal - memAvailable, ramTotalKB: memTotal, swapUsedKB: swapTotal - swapFree, swapTotalKB: swapTotal, } } func gb(kb int64) float64 { return float64(kb) / (1024 * 1024) } func accountingText(m memStatus) string { var swapPct float64 if m.swapTotalKB > 0 { swapPct = float64(m.swapUsedKB) / float64(m.swapTotalKB) * 100 } return fmt.Sprintf( "Swap: %.2f GB used / %.2f GB total (%.0f%% full) RAM: %.2f GB used / %.2f GB total", gb(m.swapUsedKB), gb(m.swapTotalKB), swapPct, gb(m.ramUsedKB), gb(m.ramTotalKB), ) } func main() { gtk.Init(nil) win, err := gtk.WindowNew(gtk.WINDOW_TOPLEVEL) if err != nil { log.Fatalf("could not create window: %v", err) } win.SetTitle("Force Quit Monitor") win.SetDefaultSize(700, 450) // Closing the window just hides it — this process must keep running so // the watchdog can reveal it again later without spawning anything new. win.Connect("delete-event", func() bool { win.Iconify() return true }) box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 8) box.SetBorderWidth(8) win.Add(box) warningLabel, _ := gtk.LabelNew("") warningLabel.SetMarkup(`⚠ SWAP IS FULL`) warningLabel.SetHAlign(gtk.ALIGN_START) box.PackStart(warningLabel, false, false, 0) accountingLabel, _ := gtk.LabelNew(accountingText(readMemStatus())) accountingLabel.SetHAlign(gtk.ALIGN_START) box.PackStart(accountingLabel, false, false, 0) label, _ := gtk.LabelNew("Sorted by memory + swap used, highest first. Select a process and force quit it.") label.SetHAlign(gtk.ALIGN_START) box.PackStart(label, false, false, 0) // Columns: PID, Name, RSS (MB), Swap (MB), Total (MB) store, _ := gtk.ListStoreNew(glib.TYPE_INT, glib.TYPE_STRING, glib.TYPE_STRING, glib.TYPE_STRING, glib.TYPE_STRING) treeView, _ := gtk.TreeViewNewWithModel(store) addColumn := func(title string, idx int) { renderer, _ := gtk.CellRendererTextNew() col, _ := gtk.TreeViewColumnNewWithAttribute(title, renderer, "text", idx) col.SetSortColumnID(idx) treeView.AppendColumn(col) } addColumn("PID", 0) addColumn("Name", 1) addColumn("RSS (MB)", 2) addColumn("Swap (MB)", 3) addColumn("Total (MB)", 4) scrolled, _ := gtk.ScrolledWindowNew(nil, nil) scrolled.SetPolicy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) scrolled.Add(treeView) box.PackStart(scrolled, true, true, 0) statusLabel, _ := gtk.LabelNew("") statusLabel.SetHAlign(gtk.ALIGN_START) box.PackStart(statusLabel, false, false, 0) killButton, _ := gtk.ButtonNewWithLabel("Force Quit Selected") box.PackStart(killButton, false, false, 0) // selectedPID reads the PID column of the current selection, if any. // Used both to act on the selected row and, in refresh, to find the // matching row again after the store is rebuilt — GtkTreeSelection // tracks a TreeIter, which store.Clear() invalidates, so without this // every refresh would silently drop the highlight even though nothing // the user cares about changed. selectedPID := func() (int, bool) { sel, err := treeView.GetSelection() if err != nil { return 0, false } model, iter, ok := sel.GetSelected() if !ok { return 0, false } val, err := model.(*gtk.TreeModel).GetValue(iter, 0) if err != nil { return 0, false } pidVal, err := val.GoValue() if err != nil { return 0, false } pid, ok := pidVal.(int) return pid, ok } refresh := func() { accountingLabel.SetText(accountingText(readMemStatus())) prevPID, hadSelection := selectedPID() vadj := scrolled.GetVAdjustment() scrollPos := vadj.GetValue() store.Clear() var reselect *gtk.TreeIter for _, p := range readProcesses() { iter := store.Append() store.Set(iter, []int{0, 1, 2, 3, 4}, []interface{}{ p.pid, p.name, fmt.Sprintf("%.1f", float64(p.rssKB)/1024), fmt.Sprintf("%.1f", float64(p.swapKB)/1024), fmt.Sprintf("%.1f", float64(p.totalKB)/1024), }, ) if hadSelection && p.pid == prevPID { reselect = iter } } if reselect != nil { if sel, err := treeView.GetSelection(); err == nil { sel.SelectIter(reselect) } } // store.Clear() resets the scrollbar to the top immediately, but // GTK doesn't recompute the adjustment's new upper bound until its // next layout pass — setting the value back right here would just // get clamped against the stale (just-emptied) bound. Deferring to // an idle callback runs this after that layout pass instead. glib.IdleAdd(func() { vadj.SetValue(scrollPos) }) } refresh() glib.TimeoutAdd(refreshIntervalMs, func() bool { refresh() return true // keep repeating }) killButton.Connect("clicked", func() { pid, ok := selectedPID() if !ok { statusLabel.SetText("No process selected.") return } if err := syscall.Kill(pid, syscall.SIGKILL); err != nil { statusLabel.SetText(fmt.Sprintf("Failed to kill PID %d: %v", pid, err)) } else { statusLabel.SetText(fmt.Sprintf("Killed PID %d.", pid)) } refresh() }) win.ShowAll() // Deferred so the window has actually been mapped before we ask the // compositor to minimize it, rather than racing the initial show. glib.TimeoutAdd(200, func() bool { win.Iconify() return false // run once }) gtk.Main() }