nandi/memwatchpublic Fork 0
937fc8b
Commits
Clone
git clone https://git.rickub.com/nandi/memwatch.git
git clone ssh://git@rickub.com/nandi/memwatch.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

fix: grant CAP_SYS_RESOURCE so the PSI trigger registers

The daemon died at startup with EINVAL on every write to
/proc/pressure/memory, while the identical write succeeded from a plain
root shell.

The kernel gates PSI trigger resolution on CAP_SYS_RESOURCE as held in
the file credentials at open() time. Without it, only windows that are a
whole multiple of 2s are accepted, and a finer one is refused with EINVAL
rather than EPERM -- which is why this read as a malformed-argument bug
rather than a permissions one. Our 100ms/1s window needs the cap.

Add it to both capability lines. Also stop treating the refusal as fatal:
fall back to a 2s window with a proportionally scaled threshold, so the
same stall fraction still fires, just with coarser latency, and say so in
the log.

Verified: service comes up armed at the configured 100000us/1000000us
with no fallback warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandithebull committed 2026-09-20T16:00:07-07:00 Browse files
937fc8b parent: 83ca73b
modified src/memwatchd.py +29 -1
@@ -32,6 +32,8 @@ import time
3232
3333 CONFIG_PATH = "/etc/memwatch.conf"
3434 PSI_PATH = "/proc/pressure/memory"
35+# Smallest trigger window the kernel allows without CAP_SYS_RESOURCE.
36+UNPRIV_WINDOW_US = 2000000
3537 CGROUP_ROOT = "/sys/fs/cgroup"
3638
3739 DEFAULTS = {
@@ -150,7 +152,33 @@ def open_psi_trigger(stall_us, window_us):
150152 looking at a frozen cursor.
151153 """
152154 fd = os.open(PSI_PATH, os.O_RDWR | os.O_NONBLOCK)
153- os.write(fd, f"full {stall_us} {window_us}".encode())
155+ try:
156+ os.write(fd, f"full {stall_us} {window_us}".encode())
157+ return fd
158+ except OSError as exc:
159+ if exc.errno != errno.EINVAL:
160+ os.close(fd)
161+ raise
162+
163+ # EINVAL here almost always means we lack CAP_SYS_RESOURCE. Without it the
164+ # kernel only accepts windows that are a whole multiple of 2s, and refuses
165+ # anything finer with EINVAL rather than EPERM. Round the window up to the
166+ # next 2s and scale the threshold to keep the same stall *fraction*, so we
167+ # still fire on the same severity -- just with coarser latency.
168+ os.close(fd)
169+ coarse_window = max(UNPRIV_WINDOW_US, -(-window_us // UNPRIV_WINDOW_US) * UNPRIV_WINDOW_US)
170+ coarse_stall = max(1, round(stall_us * coarse_window / window_us))
171+ fd = os.open(PSI_PATH, os.O_RDWR | os.O_NONBLOCK)
172+ try:
173+ os.write(fd, f"full {coarse_stall} {coarse_window}".encode())
174+ except OSError:
175+ os.close(fd)
176+ raise
177+ log(
178+ f"WARN: no CAP_SYS_RESOURCE; fell back to a {coarse_window}us window "
179+ f"({coarse_stall}us stall). Detection is up to "
180+ f"{(coarse_window - window_us) / 1e6:.1f}s slower than configured."
181+ )
154182 return fd
155183
156184
@@ -32,6 +32,8 @@ import time
32 32
33 CONFIG_PATH = "/etc/memwatch.conf"33 CONFIG_PATH = "/etc/memwatch.conf"
34 PSI_PATH = "/proc/pressure/memory"34 PSI_PATH = "/proc/pressure/memory"
35+# Smallest trigger window the kernel allows without CAP_SYS_RESOURCE.
36+UNPRIV_WINDOW_US = 2000000
35 CGROUP_ROOT = "/sys/fs/cgroup"37 CGROUP_ROOT = "/sys/fs/cgroup"
36 38
37 DEFAULTS = {39 DEFAULTS = {
@@ -150,7 +152,33 @@ def open_psi_trigger(stall_us, window_us):
150 looking at a frozen cursor.152 looking at a frozen cursor.
151 """153 """
152 fd = os.open(PSI_PATH, os.O_RDWR | os.O_NONBLOCK)154 fd = os.open(PSI_PATH, os.O_RDWR | os.O_NONBLOCK)
153- os.write(fd, f"full {stall_us} {window_us}".encode())155+ try:
156+ os.write(fd, f"full {stall_us} {window_us}".encode())
157+ return fd
158+ except OSError as exc:
159+ if exc.errno != errno.EINVAL:
160+ os.close(fd)
161+ raise
162+
163+ # EINVAL here almost always means we lack CAP_SYS_RESOURCE. Without it the
164+ # kernel only accepts windows that are a whole multiple of 2s, and refuses
165+ # anything finer with EINVAL rather than EPERM. Round the window up to the
166+ # next 2s and scale the threshold to keep the same stall *fraction*, so we
167+ # still fire on the same severity -- just with coarser latency.
168+ os.close(fd)
169+ coarse_window = max(UNPRIV_WINDOW_US, -(-window_us // UNPRIV_WINDOW_US) * UNPRIV_WINDOW_US)
170+ coarse_stall = max(1, round(stall_us * coarse_window / window_us))
171+ fd = os.open(PSI_PATH, os.O_RDWR | os.O_NONBLOCK)
172+ try:
173+ os.write(fd, f"full {coarse_stall} {coarse_window}".encode())
174+ except OSError:
175+ os.close(fd)
176+ raise
177+ log(
178+ f"WARN: no CAP_SYS_RESOURCE; fell back to a {coarse_window}us window "
179+ f"({coarse_stall}us stall). Detection is up to "
180+ f"{(coarse_window - window_us) / 1e6:.1f}s slower than configured."
181+ )
154 return fd182 return fd
155 183
156 184
modified units/memwatch.service +5 -2
@@ -25,8 +25,11 @@ IOWeight=10000
2525 Nice=-10
2626
2727 # mlockall() and SCHED_RR, plus the cgroup writes used to freeze and kill.
28-AmbientCapabilities=CAP_IPC_LOCK CAP_SYS_NICE CAP_KILL CAP_SYS_PTRACE
29-CapabilityBoundingSet=CAP_IPC_LOCK CAP_SYS_NICE CAP_KILL CAP_SYS_PTRACE CAP_SETUID CAP_SETGID CAP_DAC_OVERRIDE
28+# CAP_SYS_RESOURCE is what lets us register a PSI trigger with a sub-2s window:
29+# the kernel only allows windows that are a multiple of 2s without it, and
30+# reports the refusal as EINVAL rather than EPERM.
31+AmbientCapabilities=CAP_IPC_LOCK CAP_SYS_NICE CAP_KILL CAP_SYS_PTRACE CAP_SYS_RESOURCE
32+CapabilityBoundingSet=CAP_IPC_LOCK CAP_SYS_NICE CAP_KILL CAP_SYS_PTRACE CAP_SYS_RESOURCE CAP_SETUID CAP_SETGID CAP_DAC_OVERRIDE
3033 LimitMEMLOCK=infinity
3134 LimitRTPRIO=99
3235
@@ -25,8 +25,11 @@ IOWeight=10000
25 Nice=-1025 Nice=-10
26 26
27 # mlockall() and SCHED_RR, plus the cgroup writes used to freeze and kill.27 # mlockall() and SCHED_RR, plus the cgroup writes used to freeze and kill.
28-AmbientCapabilities=CAP_IPC_LOCK CAP_SYS_NICE CAP_KILL CAP_SYS_PTRACE28+# CAP_SYS_RESOURCE is what lets us register a PSI trigger with a sub-2s window:
29-CapabilityBoundingSet=CAP_IPC_LOCK CAP_SYS_NICE CAP_KILL CAP_SYS_PTRACE CAP_SETUID CAP_SETGID CAP_DAC_OVERRIDE29+# the kernel only allows windows that are a multiple of 2s without it, and
30+# reports the refusal as EINVAL rather than EPERM.
31+AmbientCapabilities=CAP_IPC_LOCK CAP_SYS_NICE CAP_KILL CAP_SYS_PTRACE CAP_SYS_RESOURCE
32+CapabilityBoundingSet=CAP_IPC_LOCK CAP_SYS_NICE CAP_KILL CAP_SYS_PTRACE CAP_SYS_RESOURCE CAP_SETUID CAP_SETGID CAP_DAC_OVERRIDE
30 LimitMEMLOCK=infinity33 LimitMEMLOCK=infinity
31 LimitRTPRIO=9934 LimitRTPRIO=99
32 35