package localapi import ( "bufio" "fmt" "net/http" "os" "path/filepath" "strconv" "strings" ) // R-528 — the OOM signal from the host side. // // MEASURED 2026-09-15 (scratch 9202, Docker 29.8.0) and 2026-09-16 (a fresh install in a nested VM): an app killed by // its memory limit inside a Felhom LXC guest left Docker's State.OOMKilled false and sent no `oom` event, so the // controller's OOM line (R-514) could not fire. The kernel's own counter does count every kill, and the host can read // it: /sys/fs/cgroup/lxc//ns/system.slice/docker-<64-hex id>.scope/memory.events, line `oom_kill N` (the // systemd cgroup driver; `ns/docker//` under the cgroupfs driver). Read on Tester 1's host 2026-10-28: the files // are mode 0444, readable by the non-root felhom-agent user — no sudo, no new root path. // // A container's scope is new at each start, so the counter is per container RUN: a count above 0 means this run had a // kill. The controller asks every 30 s and raises its existing app_oom line (once per run) for any such container. // readGuestOOMKills returns container id → oom_kill for one guest. available=false (no error) when the guest has no // cgroup folder (a VM guest, or a container that is not running). func readGuestOOMKills(root string, vmid int) (map[string]int64, bool, error) { if root == "" { root = "/sys/fs/cgroup" } base := filepath.Join(root, "lxc", strconv.Itoa(vmid), "ns") if fi, err := os.Stat(base); err != nil || !fi.IsDir() { return nil, false, nil } out := map[string]int64{} for _, pattern := range []string{ filepath.Join(base, "system.slice", "docker-*.scope", "memory.events"), filepath.Join(base, "docker", "*", "memory.events"), } { files, err := filepath.Glob(pattern) if err != nil { return nil, true, err } for _, f := range files { id := strings.TrimSuffix(strings.TrimPrefix(filepath.Base(filepath.Dir(f)), "docker-"), ".scope") if !isContainerID(id) { continue } n, err := readOOMKill(f) if err != nil { continue // a container that stopped between the glob and the read } out[id] = n } } return out, true, nil } func isContainerID(s string) bool { if len(s) != 64 { return false } for _, c := range s { if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { return false } } return true } func readOOMKill(path string) (int64, error) { f, err := os.Open(path) if err != nil { return 0, err } defer f.Close() sc := bufio.NewScanner(f) for sc.Scan() { k, v, ok := strings.Cut(strings.TrimSpace(sc.Text()), " ") if ok && k == "oom_kill" { return strconv.ParseInt(strings.TrimSpace(v), 10, 64) } } if err := sc.Err(); err != nil { return 0, err } return 0, fmt.Errorf("no oom_kill line in %s", path) } // handleGuestOOMKills is GET /guest/oom-kills: {"vmid", "available", "containers": {id: oom_kill}}. func (s *Server) handleGuestOOMKills(w http.ResponseWriter, r *http.Request, vmid int) { kills, avail, err := readGuestOOMKills(s.cgroupRoot, vmid) if err != nil { s.logger.Warn("local-api: guest oom-kills read failed (R-528)", "vmid", vmid, "err", err) writeErr(w, http.StatusInternalServerError, "the guest's OOM counters could not be read") return } if kills == nil { kills = map[string]int64{} } writeOK(w, map[string]any{"vmid": vmid, "available": avail, "containers": kills}) }