commit c1ef35d0399e721ff712471d20e05507983e53ac Author: Andy Dixon Date: Wed Oct 7 12:21:09 2026 +0100 Initial Version diff --git a/README.md b/README.md new file mode 100644 index 0000000..86cf334 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# swaptop + +htop-style view of who is using swap and how. Linux only. + + go build -o swaptop . && sudo ./swaptop + +Without root you only see full detail for your own processes, and the actions need root. + +Header: swap bar split into green (owned by processes), yellow (swap cache: in RAM *and* holding a swap slot, i.e. reserved but not really swapped out) and magenta (tmpfs/shm, not owned by a process). zswap and zram figures appear when present. + +Columns: SWAP (in swap now), SWPPSS (proportional share of shared pages), DELTA (change since last refresh), RSS, SWAPPED (share of the process that is in swap). + +Keys: `Enter` per-mapping breakdown · `F7` swap a process back in · `F8` page a process out (`process_madvise`, Linux 5.10+) · `F2` flush every swap device (swapoff+swapon) · `F9` kill · `F3` `/` search · `F6` `s` sort · `z` show zero-swap processes · `c` full command line · `F1` help · `q` quit. diff --git a/actions.go b/actions.go new file mode 100644 index 0000000..346478d --- /dev/null +++ b/actions.go @@ -0,0 +1,182 @@ +package main + +import ( + "encoding/binary" + "errors" + "fmt" + "os" + "strconv" + "strings" + "syscall" + "unsafe" +) + +const ( + sysPidfdOpen = 434 // same number on every arch + sysProcessMadvise = 440 + madvPageout = 21 + swapFlagPrefer = 0x8000 + swapFlagPrioMask = 0x7fff +) + +// swapIn faults every swapped-out page of pid back into RAM by reading it +// through /proc/pid/mem. Pages are located via /proc/pid/pagemap so only +// swapped pages are touched. Needs ptrace access to the target. +func swapIn(pid int, progress func(doneKB, totalKB uint64)) error { + maps, err := readSmaps(pid) + if err != nil { + return err + } + dir := "/proc/" + strconv.Itoa(pid) + mem, err := os.Open(dir + "/mem") + if err != nil { + return err + } + defer mem.Close() + pm, err := os.Open(dir + "/pagemap") + if err != nil { + return err + } + defer pm.Close() + + page := uint64(os.Getpagesize()) + var total, done uint64 + for _, m := range maps { + total += m.Swap + } + const chunk = 4096 // pagemap entries per read + ents := make([]byte, chunk*8) + buf := make([]byte, 256*page) + swapped := func(i int) bool { + e := binary.LittleEndian.Uint64(ents[i*8:]) + return e&(1<<62) != 0 && e&(1<<63) == 0 // in swap, not present + } + for _, m := range maps { + if m.Swap == 0 { + continue + } + if shmem(m) { + // swapped shmem pages leave no swap PTE behind (the entry lives in the + // page cache), so pagemap can't find them: just read the whole range. + for addr := m.Start; addr < m.End; addr += uint64(len(buf)) { + n := min(m.End-addr, uint64(len(buf))) + mem.ReadAt(buf[:n], int64(addr)) + done += n / 1024 + progress(min(done, total), total) + } + continue + } + for addr := m.Start; addr < m.End; addr += chunk * page { + n := min((m.End-addr)/page, chunk) + got, err := pm.ReadAt(ents[:n*8], int64(addr/page*8)) + if got == 0 && err != nil { + break + } + n = uint64(got / 8) + for i := 0; i < int(n); { + if !swapped(i) { + i++ + continue + } + j := i + 1 + for j < int(n) && j-i < 256 && swapped(j) { + j++ + } + mem.ReadAt(buf[:uint64(j-i)*page], int64(addr+uint64(i)*page)) // errors (guard pages etc.) are fine + done += uint64(j-i) * page / 1024 + progress(done, total) + i = j + } + } + } + return nil +} + +// swapOut asks the kernel to reclaim pid's resident pages right now +// (process_madvise MADV_PAGEOUT, Linux 5.10+). Anonymous pages go to swap, +// clean file-backed pages are simply dropped. Returns bytes advised. +func swapOut(pid int) (uint64, error) { + maps, err := readSmaps(pid) + if err != nil { + return 0, err + } + fd, _, e := syscall.Syscall(sysPidfdOpen, uintptr(pid), 0, 0) + if e != 0 { + return 0, fmt.Errorf("pidfd_open: %w", e) + } + defer syscall.Close(int(fd)) + var advised uint64 + var lastErr error + for _, m := range maps { + if m.Rss == 0 || skipPageout(m) { + continue + } + iov := struct{ base, len uintptr }{uintptr(m.Start), uintptr(m.End - m.Start)} + n, _, e := syscall.Syscall6(sysProcessMadvise, fd, uintptr(unsafe.Pointer(&iov)), 1, madvPageout, 0, 0) + if e != 0 { + lastErr = e + continue + } + advised += uint64(n) + } + if advised == 0 && lastErr != nil { + if errors.Is(lastErr, syscall.ENOSYS) { + return 0, errors.New("process_madvise unsupported (needs Linux 5.10+)") + } + return 0, fmt.Errorf("process_madvise: %w", lastErr) + } + return advised, nil +} + +// ponytail: heuristic; a MAP_PRIVATE mapping of a tmpfs file is missed. +func shmem(m Mapping) bool { + if len(m.Perms) > 3 && m.Perms[3] == 's' { + return true + } + for _, p := range []string{"/dev/zero", "/dev/shm/", "/memfd:", "/SYSV"} { + if strings.HasPrefix(m.Path, p) { + return true + } + } + return false +} + +func skipPageout(m Mapping) bool { + switch m.Path { + case "[vsyscall]", "[vvar]", "[vdso]": + return true + } + for _, f := range []string{" lo", " pf", " io", " ht"} { // locked, pfnmap, io, hugetlb + if strings.Contains(" "+m.Flags+" ", f+" ") { + return true + } + } + return false +} + +// flushDev empties a swap device by swapoff+swapon, keeping its priority. +// Needs root and enough free RAM to hold everything currently on the device. +func flushDev(d SwapDev) error { + p, err := syscall.BytePtrFromString(d.Path) + if err != nil { + return err + } + if _, _, e := syscall.Syscall(syscall.SYS_SWAPOFF, uintptr(unsafe.Pointer(p)), 0, 0); e != 0 { + return fmt.Errorf("swapoff %s: %w", d.Path, e) + } + flags := uintptr(0) + if d.Prio >= 0 { + flags = swapFlagPrefer | uintptr(d.Prio&swapFlagPrioMask) + } + if _, _, e := syscall.Syscall(syscall.SYS_SWAPON, uintptr(unsafe.Pointer(p)), flags, 0); e != 0 { + return fmt.Errorf("swapon %s: %w (device is now OFF - re-enable manually)", d.Path, e) + } + return nil +} + +func permHint(err error) string { + if errors.Is(err, os.ErrPermission) || errors.Is(err, syscall.EPERM) || errors.Is(err, syscall.EACCES) { + return " (try: sudo swaptop)" + } + return "" +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dc6c919 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module swaptop + +go 1.24.0 + +require github.com/gdamore/tcell/v2 v2.13.10 + +require ( + github.com/gdamore/encoding v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..992dc6d --- /dev/null +++ b/go.sum @@ -0,0 +1,45 @@ +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA= +github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/main.go b/main.go new file mode 100644 index 0000000..b549f7c --- /dev/null +++ b/main.go @@ -0,0 +1,775 @@ +package main + +import ( + "fmt" + "os" + "sort" + "strings" + "sync" + "syscall" + "time" + + "github.com/gdamore/tcell/v2" +) + +type view int + +const ( + viewList view = iota + viewDetail + viewHelp +) + +var sortNames = []string{"SWAP", "SWPPSS", "DELTA", "RSS", "SWAPPED", "PID", "USER", "COMMAND"} + +type modal struct { + title string + lines []string + onYes func() // nil = info box +} + +type app struct { + scr tcell.Screen + sys *Sys + prev map[int]uint64 + rows []Proc + sel, top, dtop int + sortBy int + filter string + searching bool + hideZero bool + fullCmd bool + view view + detail Proc + maps []Mapping + mapsErr error + modal *modal + busy bool + + mu sync.Mutex + status string +} + +var ( + stDefault = tcell.StyleDefault + stHdr = tcell.StyleDefault.Foreground(tcell.ColorBlack).Background(tcell.ColorGreen) + stSel = tcell.StyleDefault.Foreground(tcell.ColorBlack).Background(tcell.ColorTeal) + stKey = tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorBlack) + stFn = tcell.StyleDefault.Foreground(tcell.ColorBlack).Background(tcell.ColorTeal) + stLabel = tcell.StyleDefault.Foreground(tcell.ColorTeal).Bold(true) + stDim = tcell.StyleDefault.Foreground(tcell.ColorGray) + stWarn = tcell.StyleDefault.Foreground(tcell.ColorYellow).Bold(true) + stErr = tcell.StyleDefault.Foreground(tcell.ColorRed).Bold(true) + stGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen) + stYellow = tcell.StyleDefault.Foreground(tcell.ColorYellow) + stBlue = tcell.StyleDefault.Foreground(tcell.ColorBlue) + stMagenta = tcell.StyleDefault.Foreground(tcell.ColorFuchsia) + stRed = tcell.StyleDefault.Foreground(tcell.ColorRed) +) + +func main() { + scr, err := tcell.NewScreen() + if err == nil { + err = scr.Init() + } + if err != nil { + fmt.Fprintln(os.Stderr, "swaptop:", err) + os.Exit(1) + } + defer scr.Fini() + a := &app{scr: scr, hideZero: true} + a.sample() + events := make(chan tcell.Event, 64) + go func() { + for { + ev := scr.PollEvent() + if ev == nil { + return + } + events <- ev + } + }() + tick := time.NewTicker(2 * time.Second) + for { + a.draw() + select { + case ev := <-events: + if a.handle(ev) { + return + } + case <-tick.C: + a.sample() + } + } +} + +func (a *app) sample() { + a.sys = collect(a.prev) + a.prev = make(map[int]uint64, len(a.sys.Procs)) + for _, p := range a.sys.Procs { + a.prev[p.PID] = p.Swap + } + a.rebuild() + if a.view == viewDetail { + for _, p := range a.sys.Procs { + if p.PID == a.detail.PID { + a.detail = p + } + } + a.maps, a.mapsErr = readSmaps(a.detail.PID) + } +} + +func pct(p Proc) int { + if p.Swap+p.RSS == 0 { + return 0 + } + return int(p.Swap * 100 / (p.Swap + p.RSS)) +} + +func (a *app) rebuild() { + selPID := -1 + if a.sel < len(a.rows) { + selPID = a.rows[a.sel].PID + } + a.rows = a.rows[:0] + f := strings.ToLower(a.filter) + for _, p := range a.sys.Procs { + if a.hideZero && p.Swap == 0 { + continue + } + if f != "" && !strings.Contains(strings.ToLower(p.Cmd+" "+p.Comm+" "+p.User+" "+fmt.Sprint(p.PID)), f) { + continue + } + a.rows = append(a.rows, p) + } + sort.SliceStable(a.rows, func(i, j int) bool { + x, y := a.rows[i], a.rows[j] + switch sortNames[a.sortBy] { + case "SWPPSS": + return x.SwapPss > y.SwapPss + case "DELTA": + return x.Delta > y.Delta + case "RSS": + return x.RSS > y.RSS + case "SWAPPED": + return pct(x) > pct(y) + case "PID": + return x.PID < y.PID + case "USER": + return x.User < y.User + case "COMMAND": + return x.Comm < y.Comm + } + return x.Swap > y.Swap + }) + a.sel = 0 + for i, p := range a.rows { + if p.PID == selPID { + a.sel = i + } + } +} + +// ---- input ---- + +func (a *app) handle(ev tcell.Event) (quit bool) { + switch ev := ev.(type) { + case *tcell.EventResize: + a.scr.Sync() + case *tcell.EventInterrupt: + if ev.Data() == "done" { + a.busy = false + a.sample() + } + case *tcell.EventKey: + return a.key(ev) + } + return false +} + +func (a *app) key(ev *tcell.EventKey) bool { + if ev.Key() == tcell.KeyCtrlC { + return true + } + if m := a.modal; m != nil { + switch { + case m.onYes != nil && (ev.Rune() == 'y' || ev.Rune() == 'Y'): + a.modal = nil + m.onYes() + case m.onYes == nil || ev.Key() == tcell.KeyEscape || ev.Rune() == 'n' || ev.Rune() == 'N' || ev.Rune() == 'q': + a.modal = nil + } + return false + } + if a.searching { + switch ev.Key() { + case tcell.KeyEscape: + a.filter, a.searching = "", false + case tcell.KeyEnter: + a.searching = false + case tcell.KeyBackspace, tcell.KeyBackspace2: + if a.filter != "" { + a.filter = a.filter[:len(a.filter)-1] + } + case tcell.KeyRune: + a.filter += string(ev.Rune()) + } + a.rebuild() + return false + } + if a.view == viewHelp { + a.view = viewList + return false + } + n := len(a.rows) + if a.view == viewDetail { + n = len(a.maps) + } + pos := &a.sel + if a.view == viewDetail { + pos = &a.dtop + } + _, h := a.scr.Size() + pg := max(h-10, 1) + switch ev.Key() { + case tcell.KeyUp: + *pos-- + case tcell.KeyDown: + *pos++ + case tcell.KeyPgUp: + *pos -= pg + case tcell.KeyPgDn: + *pos += pg + case tcell.KeyHome: + *pos = 0 + case tcell.KeyEnd: + *pos = n - 1 + case tcell.KeyEnter: + if a.view == viewList && a.sel < len(a.rows) { + a.detail, a.dtop = a.rows[a.sel], 0 + a.maps, a.mapsErr = readSmaps(a.detail.PID) + a.view = viewDetail + } else { + a.view = viewList + } + case tcell.KeyEscape: + a.view = viewList + case tcell.KeyF1: + a.view = viewHelp + case tcell.KeyF2: + a.askFlush() + case tcell.KeyF3: + a.searching = true + case tcell.KeyF6: + a.sortBy = (a.sortBy + 1) % len(sortNames) + a.rebuild() + case tcell.KeyF7: + a.askSwapIn() + case tcell.KeyF8: + a.askSwapOut() + case tcell.KeyF9: + a.askKill() + case tcell.KeyF10: + return true + case tcell.KeyRune: + switch ev.Rune() { + case 'q': + if a.view == viewList { + return true + } + a.view = viewList + case '?', 'h': + a.view = viewHelp + case '/': + a.searching = true + case 's': + a.sortBy = (a.sortBy + 1) % len(sortNames) + a.rebuild() + case 'z': + a.hideZero = !a.hideZero + a.rebuild() + case 'c': + a.fullCmd = !a.fullCmd + case 'i': + a.askSwapIn() + case 'o': + a.askSwapOut() + case 'k': + a.askKill() + case 'F': + a.askFlush() + } + } + *pos = max(0, min(*pos, n-1)) + return false +} + +func (a *app) current() (Proc, bool) { + if a.view == viewDetail { + return a.detail, true + } + if a.sel < len(a.rows) { + return a.rows[a.sel], true + } + return Proc{}, false +} + +func (a *app) info(title string, lines ...string) { a.modal = &modal{title: title, lines: lines} } + +func (a *app) runJob(fn func(set func(string)) error) { + if a.busy { + a.info("Busy", "Another action is still running.") + return + } + a.busy = true + set := func(s string) { + a.mu.Lock() + a.status = s + a.mu.Unlock() + a.scr.PostEvent(tcell.NewEventInterrupt(nil)) + } + go func() { + if err := fn(set); err != nil { + set("Error: " + err.Error() + permHint(err)) + } + a.scr.PostEvent(tcell.NewEventInterrupt("done")) + }() +} + +func (a *app) askSwapIn() { + p, ok := a.current() + if !ok { + return + } + if p.Swap == 0 { + a.info("Nothing to do", fmt.Sprintf("%s (%d) has no pages in swap.", p.Comm, p.PID)) + return + } + a.modal = &modal{title: "Swap in", lines: []string{ + fmt.Sprintf("Pull %s of %s (%d) back from swap into RAM?", human(p.Swap), p.Comm, p.PID), + fmt.Sprintf("Available RAM: %s", human(a.sys.Mem["MemAvailable"])), + }, onYes: func() { + a.runJob(func(set func(string)) error { + start, last := time.Now(), time.Time{} + err := swapIn(p.PID, func(done, total uint64) { + if time.Since(last) > 100*time.Millisecond { + last = time.Now() + set(fmt.Sprintf("Swapping in %s (%d): %s / %s", p.Comm, p.PID, human(done), human(total))) + } + }) + if err == nil { + set(fmt.Sprintf("Swapped in %s (%d) in %s. Its slots stay 'cached' until the kernel drops them or you flush (F2).", + p.Comm, p.PID, time.Since(start).Round(100*time.Millisecond))) + } + return err + }) + }} +} + +func (a *app) askSwapOut() { + p, ok := a.current() + if !ok { + return + } + a.modal = &modal{title: "Swap out", lines: []string{ + fmt.Sprintf("Ask the kernel to page out %s of %s (%d) now?", human(p.RSS), p.Comm, p.PID), + "Anonymous memory goes to swap, clean file pages are dropped.", + "The process keeps running but will be slow until it faults back in.", + }, onYes: func() { + a.runJob(func(set func(string)) error { + set(fmt.Sprintf("Swapping out %s (%d)...", p.Comm, p.PID)) + n, err := swapOut(p.PID) + if err == nil { + set(fmt.Sprintf("Advised kernel to page out %s of %s (%d).", human(n/1024), p.Comm, p.PID)) + } + return err + }) + }} +} + +func (a *app) askKill() { + p, ok := a.current() + if !ok { + return + } + a.modal = &modal{title: "Kill", lines: []string{fmt.Sprintf("Send SIGTERM to %s (%d)?", p.Comm, p.PID)}, onYes: func() { + if err := syscall.Kill(p.PID, syscall.SIGTERM); err != nil { + a.info("Kill failed", err.Error()+permHint(err)) + } + }} +} + +func (a *app) askFlush() { + used, avail := a.sys.SwapUsed(), a.sys.Mem["MemAvailable"] + if len(a.sys.Devs) == 0 { + a.info("No swap", "No active swap devices.") + return + } + if os.Geteuid() != 0 { + a.info("Need root", "Flushing a swap device (swapoff + swapon) needs root. Try: sudo swaptop") + return + } + if used > avail { + a.info("Not enough RAM", fmt.Sprintf("Swap holds %s but only %s RAM is available.", human(used), human(avail)), + "Swap in / kill some processes first, or the OOM killer will decide for you.") + return + } + lines := []string{fmt.Sprintf("swapoff + swapon every device, pulling %s back into RAM (%s available)?", human(used), human(avail))} + for _, d := range a.sys.Devs { + lines = append(lines, fmt.Sprintf(" %s used %s prio %d", d.Path, human(d.Used), d.Prio)) + } + lines = append(lines, "Priority is preserved; discard/other swapon options are not.") + devs := a.sys.Devs + a.modal = &modal{title: "Flush swap", lines: lines, onYes: func() { + a.runJob(func(set func(string)) error { + start := time.Now() + for _, d := range devs { + set("Flushing " + d.Path + " (swapoff)...") + if err := flushDev(d); err != nil { + return err + } + } + set("Flushed all swap devices in " + time.Since(start).Round(100*time.Millisecond).String()) + return nil + }) + }} +} + +// ---- drawing ---- + +func (a *app) put(x, y int, s string, st tcell.Style) int { + w, _ := a.scr.Size() + for _, r := range s { + if x >= w { + break + } + a.scr.SetContent(x, y, r, nil, st) + x++ + } + return x +} + +func (a *app) fill(y int, st tcell.Style) { + w, _ := a.scr.Size() + for x := 0; x < w; x++ { + a.scr.SetContent(x, y, ' ', nil, st) + } +} + +type seg struct { + frac float64 + st tcell.Style +} + +// bar draws an htop style Label[||||| text] of the given total width. +func (a *app) bar(x, y, width int, label string, segs []seg, text string) { + x = a.put(x, y, label, stLabel) + x = a.put(x, y, "[", stDefault) + inner := width - len(label) - 2 + if inner < 4 { + return + } + pos, filled := 0, 0 + for _, s := range segs { + n := int(s.frac*float64(inner) + 0.5) + n = min(n, inner-filled) + for i := 0; i < n; i++ { + a.scr.SetContent(x+pos, y, '|', nil, s.st) + pos++ + } + filled += n + } + if len(text) < inner { + a.put(x+inner-len(text), y, text, stDim) + } + a.put(x+inner, y, "]", stDefault) +} + +func (a *app) drawHeader(w int) int { + m := a.sys.Mem + total, used := m["SwapTotal"], a.sys.SwapUsed() + procs := min(a.sys.ProcSwap, used) + cached := min(m["SwapCached"], used-procs) + other := used - procs - cached + frac := func(v uint64) float64 { + if total == 0 { + return 0 + } + return float64(v) / float64(total) + } + memTot := m["MemTotal"] + memCache := m["Cached"] + m["SReclaimable"] + memUsed := memTot - m["MemFree"] - m["Buffers"] - memCache + mfrac := func(v uint64) float64 { return float64(v) / float64(max(memTot, 1)) } + swapSegs := []seg{{frac(procs), stGreen}, {frac(cached), stYellow}, {frac(other), stMagenta}} + memSegs := []seg{{mfrac(memUsed), stGreen}, {mfrac(m["Buffers"]), stBlue}, {mfrac(memCache), stYellow}} + y := 0 + if w >= 100 { + bw := (w - 2) / 2 + a.bar(1, y, bw, "Swp", swapSegs, human(used)+"/"+human(total)) + a.bar(1+bw+1, y, bw, "Mem", memSegs, human(memTot-m["MemAvailable"])+"/"+human(memTot)) + y++ + } else { + a.bar(1, y, w-2, "Swp", swapSegs, human(used)+"/"+human(total)) + a.bar(1, y+1, w-2, "Mem", memSegs, human(memTot-m["MemAvailable"])+"/"+human(memTot)) + y += 2 + } + x := a.put(1, y, "Swap used "+human(used)+": ", stDefault) + x = a.put(x, y, "■ "+human(procs)+" in processes ", stGreen) + x = a.put(x, y, "■ "+human(cached)+" cached (also in RAM, droppable) ", stYellow) + x = a.put(x, y, "■ "+human(other)+" tmpfs/shm/unmapped ", stMagenta) + a.put(x, y, "free "+human(total-used), stDim) + y++ + if z := m["Zswapped"]; z > 0 { + a.put(1, y, fmt.Sprintf("zswap: %s of that is held compressed in RAM (pool %s), the rest is on disk", human(z), human(m["Zswap"])), stDefault) + y++ + } + for _, d := range a.sys.Devs { + p := 0 + if d.Size > 0 { + p = int(d.Used * 100 / d.Size) + } + st := stDefault + if p >= 90 { + st = stRed + } + x := a.put(1, y, fmt.Sprintf("%-28s %-9s %8s used %s (%d%%) prio %d", d.Path, d.Type, human(d.Size), human(d.Used), p, d.Prio), st) + if d.Zram != "" { + a.put(x+2, y, d.Zram, stDim) + } + y++ + } + line := fmt.Sprintf("Commit %s / %s %d of %d processes in swap sort: %s", human(m["Committed_AS"]), human(m["CommitLimit"]), + a.sys.Swapped, len(a.sys.Procs), sortNames[a.sortBy]) + if a.hideZero { + line += " (z: show all)" + } + if a.filter != "" { + line += " filter: " + a.filter + } + x = a.put(1, y, line, stDefault) + if a.sys.Unreadable > 0 { + a.put(x+3, y, fmt.Sprintf("%d processes unreadable (not root)", a.sys.Unreadable), stWarn) + } + y++ + a.mu.Lock() + status := a.status + a.mu.Unlock() + st := stWarn + if strings.HasPrefix(status, "Error") { + st = stErr + } + a.put(1, y, status, st) + return y + 2 +} + +func (a *app) draw() { + a.scr.Clear() + w, h := a.scr.Size() + switch a.view { + case viewHelp: + a.drawHelp() + case viewDetail: + a.drawDetail(w, h) + default: + a.drawList(w, h) + } + a.drawBottom(w, h) + if a.modal != nil { + a.drawModal(w, h) + } + a.scr.Show() +} + +func (a *app) drawList(w, h int) { + y := a.drawHeader(w) + a.fill(y, stHdr) + a.put(0, y, fmt.Sprintf("%7s %-10s %8s %8s %7s %8s %-13s %s", "PID", "USER", "SWAP", "SWPPSS", "DELTA", "RSS", "SWAPPED", "COMMAND"), stHdr) + y++ + avail := h - 1 - y + if avail < 1 { + return + } + if a.sel < a.top { + a.top = a.sel + } + if a.sel >= a.top+avail { + a.top = a.sel - avail + 1 + } + for i := a.top; i < len(a.rows) && i < a.top+avail; i++ { + p := a.rows[i] + st, bst, dst := stDefault, stGreen, stDefault + if i == a.sel { + a.fill(y, stSel) + st, bst, dst = stSel, stSel, stSel + } else { + if pct(p) >= 75 { + bst = stRed + } else if pct(p) >= 25 { + bst = stYellow + } + if p.Delta > 0 { + dst = stRed + } else if p.Delta < 0 { + dst = stGreen + } + } + pss := "-" + if p.HasPss { + pss = human(p.SwapPss) + } else if p.Swap == 0 { + pss = "0K" + } + x := a.put(0, y, fmt.Sprintf("%7d %-10.10s %8s %8s ", p.PID, p.User, human(p.Swap), pss), st) + x = a.put(x, y, fmt.Sprintf("%7s", delta(p.Delta)), dst) + x = a.put(x, y, fmt.Sprintf(" %8s ", human(p.RSS)), st) + n := pct(p) * 8 / 100 + x = a.put(x, y, strings.Repeat("|", n), bst) + x = a.put(x, y, strings.Repeat(" ", 8-n)+fmt.Sprintf("%3d%% ", pct(p)), st) + cmd := p.Comm + if a.fullCmd { + cmd = p.Cmd + } + a.put(x, y, cmd, st) + y++ + } +} + +func (a *app) drawDetail(w, h int) { + p := a.detail + a.put(1, 0, fmt.Sprintf("%s (%d) user %s", p.Comm, p.PID, p.User), stLabel) + a.put(1, 1, fmt.Sprintf("Swap %s SwapPss %s RSS %s %d%% of its memory is in swap %d mappings", + human(p.Swap), human(p.SwapPss), human(p.RSS), pct(p), len(a.maps)), stDefault) + a.put(1, 2, p.Cmd, stDim) + if a.mapsErr != nil { + a.put(1, 3, "cannot read mappings: "+a.mapsErr.Error()+permHint(a.mapsErr), stErr) + } + y := 4 + a.fill(y, stHdr) + a.put(0, y, fmt.Sprintf("%8s %8s %8s %-5s %-33s %s", "SWAP", "SWPPSS", "RSS", "PERM", "ADDRESS", "MAPPING"), stHdr) + y++ + avail := h - 1 - y + if avail < 1 { + return + } + a.dtop = max(0, min(a.dtop, len(a.maps)-avail)) + for i := a.dtop; i < len(a.maps) && i < a.dtop+avail; i++ { + m := a.maps[i] + st := stDefault + if m.Swap == 0 { + st = stDim + } + path := m.Path + if path == "" { + path = "[anon]" + } + a.put(0, y, fmt.Sprintf("%8s %8s %8s %-5s %012x-%012x %s", human(m.Swap), human(m.SwapPss), human(m.Rss), m.Perms, m.Start, m.End, path), st) + y++ + } +} + +func (a *app) drawHelp() { + lines := []string{ + "swaptop - who is using swap, and how", + "", + "Columns", + " SWAP swapped memory this process maps: its anonymous pages plus any shared memory (shmem/tmpfs) it has mapped", + " SWPPSS its own share of anonymous swap (pages shared with other processes are split between them); shared memory is not counted", + " DELTA change in SWAP since the last refresh (red = being swapped out, green = coming back in)", + " RSS resident memory, in RAM", + " SWAPPED how much of the process (SWAP+RSS) is in swap right now", + "", + "Swap bar", + " green attributed to processes", + " yellow swap cache: pages that are in RAM *and* still hold a swap slot (reserved, not really swapped out; dropped instantly under pressure)", + " magenta not owned by any process: tmpfs/shm files, or slots whose owner we cannot read", + "", + "Actions (each asks for confirmation)", + " F7 i swap in: fault every swapped page of the process back into RAM (reads /proc/PID/mem)", + " F8 o swap out: ask the kernel to reclaim the process's pages now (process_madvise MADV_PAGEOUT, Linux 5.10+)", + " F2 F flush: swapoff + swapon every device, emptying swap completely (root, needs enough free RAM)", + " F9 k kill (SIGTERM)", + "", + "Keys", + " Up/Down PgUp/PgDn Home/End move Enter per-mapping breakdown of the selected process", + " F3 / search F6 s sort z show/hide processes with no swap c full command line F10 q quit", + "", + "Non-root: SWPPSS and actions only work for your own processes (kernel.yama.ptrace_scope).", + "", + "Press any key to return.", + } + for i, l := range lines { + st := stDefault + if i == 0 || (l != "" && l[0] != ' ') { + st = stLabel + } + a.put(1, i, l, st) + } +} + +func (a *app) drawBottom(w, h int) { + y := h - 1 + a.fill(y, stDefault) + if a.searching { + a.put(0, y, "Search: "+a.filter+"_", stWarn) + return + } + keys := [][2]string{{"F1", "Help"}, {"F2", "Flush"}, {"F3", "Search"}, {"F6", "SortBy"}, {"F7", "SwapIn"}, {"F8", "SwapOut"}, {"F9", "Kill"}, {"F10", "Quit"}} + if a.view != viewList { + keys = [][2]string{{"Esc", "Back"}, {"F7", "SwapIn"}, {"F8", "SwapOut"}, {"F9", "Kill"}, {"F10", "Quit"}} + } + x := 0 + for _, k := range keys { + x = a.put(x, y, fmt.Sprintf("%3s", k[0]), stKey) + x = a.put(x, y, fmt.Sprintf("%-7s", k[1]), stFn) + } +} + +func (a *app) drawModal(w, h int) { + m := a.modal + lines := append([]string{}, m.lines...) + if m.onYes != nil { + lines = append(lines, "", "[y] yes [n] no") + } else { + lines = append(lines, "", "[Enter] ok") + } + bw := len(m.title) + for _, l := range lines { + bw = max(bw, len(l)) + } + bw = min(bw+4, w) + bh := len(lines) + 2 + x0, y0 := (w-bw)/2, (h-bh)/2 + st := tcell.StyleDefault.Foreground(tcell.ColorBlack).Background(tcell.ColorSilver) + for y := y0; y < y0+bh; y++ { + for x := x0; x < x0+bw; x++ { + a.scr.SetContent(x, y, ' ', nil, st) + } + } + a.put(x0+2, y0, m.title, st.Bold(true)) + for i, l := range lines { + a.put(x0+2, y0+1+i, l, st) + } +} + +func human(kb uint64) string { + switch { + case kb < 1024: + return fmt.Sprintf("%dK", kb) + case kb < 1024*1024: + return fmt.Sprintf("%.1fM", float64(kb)/1024) + default: + return fmt.Sprintf("%.2fG", float64(kb)/1024/1024) + } +} + +func delta(kb int64) string { + switch { + case kb > 0: + return "+" + human(uint64(kb)) + case kb < 0: + return "-" + human(uint64(-kb)) + } + return "" +} diff --git a/proc.go b/proc.go new file mode 100644 index 0000000..85a103f --- /dev/null +++ b/proc.go @@ -0,0 +1,225 @@ +package main + +import ( + "bytes" + "os" + "os/user" + "sort" + "strconv" + "strings" +) + +// All sizes are in kB, matching /proc. + +type SwapDev struct { + Path, Type string + Size, Used uint64 + Prio int + Zram string // "orig→compressed" for zram devices, else "" +} + +type Proc struct { + PID, UID int + User, Comm, Cmd string + Swap, SwapPss, RSS uint64 + Delta int64 // change in Swap since previous sample + HasPss bool // smaps_rollup was readable +} + +type Mapping struct { + Start, End uint64 + Perms, Path, Flags string + Rss, Swap, SwapPss uint64 +} + +type Sys struct { + Mem map[string]uint64 // /proc/meminfo + Devs []SwapDev + Procs []Proc + ProcSwap uint64 // sum of per-process SwapPss (Swap where PSS unreadable) + Swapped int // processes with Swap > 0 + Unreadable int // swapped processes whose smaps_rollup we could not read +} + +func (s *Sys) SwapUsed() uint64 { return s.Mem["SwapTotal"] - s.Mem["SwapFree"] } + +func collect(prev map[int]uint64) *Sys { + s := &Sys{Mem: parseKV(readFile("/proc/meminfo")), Devs: readSwaps()} + ents, _ := os.ReadDir("/proc") + for _, e := range ents { + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + p, ok := readProc(pid) + if !ok { + continue + } + if pv, ok := prev[pid]; ok { + p.Delta = int64(p.Swap) - int64(pv) + } + if p.Swap > 0 { + s.Swapped++ + if p.HasPss { + s.ProcSwap += p.SwapPss + } else { + s.ProcSwap += p.Swap + s.Unreadable++ + } + } + s.Procs = append(s.Procs, p) + } + return s +} + +func readProc(pid int) (Proc, bool) { + dir := "/proc/" + strconv.Itoa(pid) + st := readFile(dir + "/status") + if st == nil { + return Proc{}, false + } + p := Proc{PID: pid} + for _, line := range strings.Split(string(st), "\n") { + k, v, ok := strings.Cut(line, ":") + if !ok { + continue + } + v = strings.TrimSpace(v) + switch k { + case "Name": + p.Comm = v + case "Uid": + p.UID, _ = strconv.Atoi(strings.Fields(v)[0]) + case "VmRSS": + p.RSS = kb(v) + case "VmSwap": + p.Swap = kb(v) + } + } + p.User = userName(p.UID) + cmd := bytes.TrimRight(readFile(dir+"/cmdline"), "\x00") + p.Cmd = string(bytes.ReplaceAll(cmd, []byte{0}, []byte{' '})) + if p.Cmd == "" { + p.Cmd = "[" + p.Comm + "]" + } + if p.Swap > 0 { + if r := readFile(dir + "/smaps_rollup"); r != nil { + m := parseKV(r) // rollup Swap also counts swapped shmem this process maps; VmSwap is anon only + p.Swap, p.SwapPss, p.HasPss = m["Swap"], m["SwapPss"], true + } + } + return p, true +} + +func readSmaps(pid int) ([]Mapping, error) { + b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/smaps") + if err != nil { + return nil, err + } + return parseSmaps(b), nil +} + +func parseSmaps(b []byte) []Mapping { + var out []Mapping + for _, line := range strings.Split(string(b), "\n") { + f := strings.Fields(line) + if len(f) == 0 { + continue + } + if !strings.HasSuffix(f[0], ":") { // VMA header: start-end perms offset dev inode [path] + lo, hi, ok := strings.Cut(f[0], "-") + if !ok || len(f) < 5 { + continue + } + m := Mapping{Perms: f[1]} + m.Start, _ = strconv.ParseUint(lo, 16, 64) + m.End, _ = strconv.ParseUint(hi, 16, 64) + if len(f) > 5 { + m.Path = strings.Join(f[5:], " ") + } + out = append(out, m) + continue + } + if len(out) == 0 { + continue + } + m := &out[len(out)-1] + switch f[0] { + case "Rss:": + m.Rss = kb(f[1]) + case "Swap:": + m.Swap = kb(f[1]) + case "SwapPss:": + m.SwapPss = kb(f[1]) + case "VmFlags:": + m.Flags = strings.Join(f[1:], " ") + } + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Swap > out[j].Swap }) + return out +} + +func readSwaps() []SwapDev { + var devs []SwapDev + for i, line := range strings.Split(string(readFile("/proc/swaps")), "\n") { + f := strings.Fields(line) + if i == 0 || len(f) < 5 { + continue + } + d := SwapDev{Path: f[0], Type: f[1], Size: kb(f[2]), Used: kb(f[3])} + d.Prio, _ = strconv.Atoi(f[4]) + if strings.HasPrefix(d.Path, "/dev/zram") { + // mm_stat: orig_data_size compr_data_size mem_used_total ... (bytes) + if f := strings.Fields(string(readFile("/sys/block/" + strings.TrimPrefix(d.Path, "/dev/") + "/mm_stat"))); len(f) >= 3 { + o, _ := strconv.ParseUint(f[0], 10, 64) + c, _ := strconv.ParseUint(f[2], 10, 64) + d.Zram = human(o/1024) + "→" + human(c/1024) + " RAM" + } + } + devs = append(devs, d) + } + return devs +} + +// parseKV parses "Key: 123 kB" style files (meminfo, smaps_rollup) into kB values. +func parseKV(b []byte) map[string]uint64 { + m := map[string]uint64{} + for _, line := range strings.Split(string(b), "\n") { + k, v, ok := strings.Cut(line, ":") + if ok { + m[k] = kb(v) + } + } + return m +} + +func kb(v string) uint64 { + f := strings.Fields(v) + if len(f) == 0 { + return 0 + } + n, _ := strconv.ParseUint(f[0], 10, 64) + return n +} + +func readFile(path string) []byte { + b, err := os.ReadFile(path) + if err != nil { + return nil + } + return b +} + +var users = map[int]string{} + +func userName(uid int) string { + if n, ok := users[uid]; ok { + return n + } + n := strconv.Itoa(uid) + if u, err := user.LookupId(n); err == nil { + n = u.Username + } + users[uid] = n + return n +} diff --git a/proc_test.go b/proc_test.go new file mode 100644 index 0000000..8d9b486 --- /dev/null +++ b/proc_test.go @@ -0,0 +1,23 @@ +package main + +import "testing" + +func TestParseSmaps(t *testing.T) { + in := []byte(`7f00-7f10 rw-p 00000000 00:00 0 [heap] +Rss: 100 kB +Swap: 40 kB +SwapPss: 20 kB +VmFlags: rd wr mr mw me ac +7f20-7f30 r--p 00000000 08:01 1234 /usr/lib/x.so (deleted) +Rss: 8 kB +Swap: 0 kB +`) + m := parseSmaps(in) + if len(m) != 2 || m[0].Path != "[heap]" || m[0].Swap != 40 || m[0].SwapPss != 20 || m[0].Rss != 100 || + m[0].Start != 0x7f00 || m[0].End != 0x7f10 || m[0].Flags != "rd wr mr mw me ac" || m[1].Path != "/usr/lib/x.so (deleted)" { + t.Fatalf("bad parse: %+v", m) + } + if parseKV([]byte("SwapTotal: 16 kB\nFoo: 3\n"))["SwapTotal"] != 16 { + t.Fatal("parseKV") + } +}