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>
937fc8b parent: 83ca73b modified
src/memwatchd.py +29 -1 | @@ -32,6 +32,8 @@ import time | ||
| 32 | 32 | |
| 33 | 33 | CONFIG_PATH = "/etc/memwatch.conf" |
| 34 | 34 | PSI_PATH = "/proc/pressure/memory" |
| 35 | +# Smallest trigger window the kernel allows without CAP_SYS_RESOURCE. | |
| 36 | +UNPRIV_WINDOW_US = 2000000 | |
| 35 | 37 | CGROUP_ROOT = "/sys/fs/cgroup" |
| 36 | 38 | |
| 37 | 39 | DEFAULTS = { |
| @@ -150,7 +152,33 @@ def open_psi_trigger(stall_us, window_us): | ||
| 150 | 152 | looking at a frozen cursor. |
| 151 | 153 | """ |
| 152 | 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 | 182 | return fd |
| 155 | 183 | |
| 156 | 184 | |
| @@ -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 fd | 182 | return fd |
| 155 | 183 | ||
| 156 | 184 | ||
modified
units/memwatch.service +5 -2 | @@ -25,8 +25,11 @@ IOWeight=10000 | ||
| 25 | 25 | Nice=-10 |
| 26 | 26 | |
| 27 | 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_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 | |
| 30 | 33 | LimitMEMLOCK=infinity |
| 31 | 34 | LimitRTPRIO=99 |
| 32 | 35 | |
| @@ -25,8 +25,11 @@ IOWeight=10000 | |||
| 25 | Nice=-10 | 25 | 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_PTRACE | 28 | +# 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_OVERRIDE | 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 | ||
| 30 | LimitMEMLOCK=infinity | 33 | LimitMEMLOCK=infinity |
| 31 | LimitRTPRIO=99 | 34 | LimitRTPRIO=99 |
| 32 | 35 | ||