oom: bound the wait for a watcher to stop#13851
Conversation
The shim calls Stop from handleProcessExit before it publishes TaskExit, and stop waited on the watcher goroutine's errCh with no bound. That wait runs on the shim's only processExits goroutine, and the goroutine it waits for can be parked reading the container's cgroup files or forwarding an event, so a watcher that does not come back stops the shim from publishing that exit and every exit after it. Clients blocked in task.Wait, the container IO fifos and the CRI exit monitors then leak for good, and the shim is left orphaned with its container already reaped, which is what the reported goroutine dumps show. Before this cgroup v2 watcher existed, handleProcessExit published the exit straight after SetExited, so this is a regression rather than a long standing gap. Keep waiting, since it is what lets a last OOM event be delivered ahead of the exit, but give up after stopTimeout and report it. Draining is sub-millisecond on a healthy system, so this only trips when something is already wrong, and losing the last OOM event is far cheaper than never publishing a task exit. Signed-off-by: Dean Chen <862469039@qq.com>
There was a problem hiding this comment.
Pull request overview
This PR addresses a regression in containerd’s cgroup v2 OOM watcher shutdown path that could indefinitely block shim exit processing, preventing TaskExit from being published and causing long-lived goroutine leaks and node-level “stuck” behavior as described in #13814.
Changes:
- Add a bounded timeout to
internal/oomwatcher shutdown soStop()can’t block forever waiting for the watcher goroutine to finish. - Add unit tests covering the non-blocking timeout behavior and ensuring watcher errors are still propagated.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| internal/oom/watcher.go | Bounds watcher.stop() with a timer so shim exit processing can’t wedge on an unbounded wait for the watcher goroutine. |
| internal/oom/watcher_test.go | Adds targeted tests ensuring stop() times out instead of hanging and still reports real watcher errors. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Signed-off-by: Dean Chen <862469039@qq.com>
|
The only red job is VM integration (almalinux-9, systemd, runc). Looking at the log, failures are from registry network flakes during image pull (not the OOM watcher change): Other matrix cells look green, including:
This PR only touches |
Signed-off-by: Dean Chen <862469039@qq.com>
|
Latest CI red jobs still look environmental:
PR still only touches |
Problem
Reported in #13814 as a v2.2.5 → v2.3.2 regression: after a pod with a VFIO/IOMMU passthrough device is deleted, its shim is orphaned — the container is reaped and the sandbox is gone from
crictl pods, but the shim process is still alive and the task exit is never delivered. containerd itself stays responsive, yet the node stops creating new sandboxes while still reportingReady, and only a runtime restart clears it.The reporter's
SIGUSR1dumps show that as a large, permanent goroutine leak — roughly two thirds to four fifths of all goroutines stuck for ~47h, matching the last runtime restart, clustered exactly where an undelivered task exit would leave them:Root cause
The regression is
handleProcessExitincmd/containerd-shim-runc-v2/task/service.go. On v2.2.5 the exit was published immediately:On 2.3.x an unbounded wait sits in front of that
send. It arrived in8ac7e3c0("cmd/containerd-shim-runc-v2: use experimental OOM package") from #12714, "Update OOMKilled event handling", merged 2026-01-07 — in 2.3.x, not in 2.2.5, which matches the reported version boundary:Stopends ininternal/oom:so publishing a task exit is made to depend on another goroutine returning. That goroutine, before it closes
errCh, both reads the container's cgroup files (memory.events) and can calleventFn→publisher.Publish. Neither is guaranteed to come back promptly while a device is being torn down, which fits the VFIO/IOMMU trigger.Two things turn that into a permanently wedged node rather than a slow exit:
processExitsis a singlefor e := range s.ecloop that calls the handlers inline, and for an init process with no running execshandleInitExitcallshandleProcessExitdirectly on it. So the stall stops every subsequent exit for that shim, not just this one.task.Wait, the IO fifos and the CRI exit monitors never return — and on restartRecoverContainerre-attaches and gets stuck again, which is why the dumps showRecoverContainergoroutines too.I could not pin down which syscall the watcher goroutine ultimately parks in (the reporter could not dump an orphaned shim, since its log target is already deleted). That is worth saying plainly — but it does not change what needs fixing: the dependency is unbounded by construction, and any stall in it costs the shim all of its exit events.
Fix
Bound the wait in
watcher.stop()and report a timeout instead of blocking forever.The wait is deliberately kept rather than removed. Closing the event FD makes the watcher do one final
memory.eventscheck, so waiting is what lets a lastTaskOOMbe delivered beforeTaskExit— the ordering #12714 set out to fix. Draining is sub-millisecond on a healthy system, so the bound only trips when something is already wrong, and there losing the last OOM annotation is far cheaper than never publishing a task exit.Alternative considered
The obvious smaller change is to move
s.cg2oom.Stop()afters.send(TaskExit), restoring 2.2.x ordering. I did not do that, for two reasons:Stopwould still run unbounded on theprocessExitsgoroutine right after thesend, so this container's exit would get through and every later exit on that shim would still be lost.Bounding fixes both. Happy to switch if maintainers prefer the ordering change, or to take the timeout as a
context.Contextargument onInterface.Stopinstead of a package constant — there is exactly one production caller, so either is cheap.2sis a starting point, not a considered SLO; say the word if you want a different value.Also worth flagging, but left alone here:
oomWatchers.Stopnever deletes the watcher from its map, so every exited container leaves an entry behind. Unrelated to this hang, so I did not fold it in.Testing
internal/oom/watcher_test.gogets a watcher whose goroutine never resolveserrCh— which is what a goroutine parked in a cgroup read looks like fromstop's side — and assertsstopstill returns. A second test asserts the watcher's own error is still propagated, so bounding the wait did not swallow it. Neither needs root or cgroups, unlike the existingTestWatcher.This package is
//go:build linuxand I am on darwin, so I ran the tests throughgo test -overlay, substituting darwin stubs for the inotify/cgroup-v2 symbols inutils.go(and the one inotify constantstart()uses).stop()itself and both tests are the real code, unmodified — the stubs are not on the path either test takes. Repo tree untouched; the overlay files live outside it.Before, against
stop()as it is on main:After:
Native linux checks, since the overlay run is not the real build:
I have not run the pre-existing
TestWatcher, which needs root and cgroup v2. The end-to-end behaviour (deleting a VFIO passthrough pod and watching the shim exit cleanly) needs the reporter's hardware and cannot be covered here either.This does not overlap #13635, which touches
start()in the same file for a different issue.