Adds a red "SWAP IS FULL" header plus a live swap/RAM usage line (used/total/% full) above the process list, refreshed alongside it, so the kill window gives context on why it popped up. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
296 lines
7.7 KiB
Go
296 lines
7.7 KiB
Go
// 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(`<span foreground="red" weight="bold" size="x-large">⚠ SWAP IS FULL</span>`)
|
|
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()
|
|
}
|