← notes Notes
2026-09
Notes

Finding a spinning event loop with no strace

2026-09-02

A CLI I use ships a systemd user timer that runs a maintenance pass every twelve minutes. It normally takes about 1.4 seconds. One morning it had been running for eighteen, holding roughly a full core the whole time.

The obvious move is strace -p. It was not available: /proc/sys/kernel/yama/ptrace_scope was 1, and I did not want to escalate to root just to look. It turns out you do not need to. Everything below is readable by the process owner with no privilege at all, and it is enough to distinguish a busy loop from real work.

Where the time goes

Fields 14 and 15 of /proc/<pid>/stat are user and system time in clock ticks. Sampling them a few seconds apart:

utime  7796  ->  8024     (+2.3 s in userspace)
stime 70429  -> 72607     (+21.8 s in the kernel)

Roughly ninety percent of the CPU was being spent inside the kernel. That already rules out a compute loop in application code. Something was calling a syscall, over and over.

The measurement that settles it

/proc/<pid>/status carries two counters that answer the question directly:

voluntary_ctxt_switches:      2726     delta over 3 s: 0
nonvoluntary_ctxt_switches:  51937

A voluntary context switch is the process giving up the CPU because it blocked, waiting on a socket, a lock or a timer. A nonvoluntary one is the scheduler taking the CPU away because the process would not stop.

Zero voluntary switches over three seconds means the process never blocked once. A healthy event loop is almost entirely voluntary switches: it calls epoll_wait, sleeps until something happens, wakes up. A process burning kernel time while never blocking is calling a syscall that returns immediately, in a tight loop. That is the whole diagnosis, and it costs one file read.

Ruling out real work

Two more counters, to be sure it was not doing something useful very fast:

No I/O, no allocation, no blocking, all kernel time. It was spinning on a non-blocking syscall, the classic shape of an event loop polling with a zero timeout. RSS was flat at 136 MB throughout, so it was not a leak either.

Why nothing stopped it

The unit was Type=oneshot. For oneshot services systemd defaults TimeoutStartUSec to infinity, so a hung run is never reaped. Worse, because the unit stays in activating (start) forever, the timer cannot fire again, so the maintenance it was supposed to perform had also silently stopped.

The journal made the pattern obvious once I looked: of 1315 recorded runs, 1313 took between 1.3 and 1.5 seconds. The two outliers were this one and an earlier one that ran 53 minutes and only ended because logout sent it SIGTERM.

The fix

A drop-in rather than an edit to the unit file, because the CLI rewrites its own unit when it reconciles the schedule:

[Service]
TimeoutStartSec=180
RuntimeMaxSec=180
CPUQuota=50%

180 rather than 120 seconds: the post-boot run legitimately takes about 99 seconds of wall time on 1.3 seconds of CPU while it waits for the network, and a tighter ceiling would kill a healthy run. The quota bounds the damage of the next hang to half a core instead of a whole one.

None of this fixes the underlying bug, which is upstream’s. It converts an unbounded failure into a bounded one, which is usually the part you own.

The short version

yoandry.dev 2026-09 NOTES