Skip to content

fix(daemon): verify process identity before signaling the persisted PID - #789

Open
iaroslav-reflection wants to merge 2 commits into
containerd:mainfrom
iaroslav-reflection:fix/pid-reuse-guard
Open

fix(daemon): verify process identity before signaling the persisted PID#789
iaroslav-reflection wants to merge 2 commits into
containerd:mainfrom
iaroslav-reflection:fix/pid-reuse-guard

Conversation

@iaroslav-reflection

Copy link
Copy Markdown
Contributor

Terminate sends SIGTERM to the PID persisted in the daemon record without checking what is actually running there. After a snapshotter restart the record can be stale, and on busy hosts PIDs are recycled quickly — the signal can land on an unrelated process (another nydusd, a shim, a container process).

flowchart LR
    A["record: PID 1234"] --> B["nydusd 1234 dies,<br/>snapshotter restarts"]
    B --> C["kernel recycles 1234<br/>for an unrelated process"]
    C --> D["DestroyDaemon → Terminate"]
    D --> E["💥 SIGTERM to the wrong process"]
Loading

Fix: record the process start time (field 22 of /proc/<pid>/stat) next to the PID at spawn. PID + start time identify a process instance — a recycled PID cannot have the same start time. Terminate verifies identity before signaling and refuses on mismatch. This is the same guard runc uses against PID reuse when killing containers (hasInit/signalInit, comparing init_process_start from its state file).

Records persisted by older versions carry no start time and keep today's unverified behavior, so existing daemons are still torn down; every record written after this change is verified. The new ConfigState field is JSON-serialized, so old records load cleanly.

Terminate sends SIGTERM to the PID persisted in the daemon record
without checking what is running there. After a snapshotter restart the
record can be stale, and on busy hosts PIDs are recycled quickly, so
the signal can hit an unrelated process: another daemon, a shim, or a
container process.

Record the process start time (field 22 of /proc/<pid>/stat) next to
the PID when nydusd is spawned; PID plus start time identify a process
instance, since a recycled PID cannot have the same start time.
Terminate refuses to signal when the start time does not match. This is
the same guard runc uses against PID reuse when killing containers.

Records without a start time (persisted by older snapshotter versions)
keep the previous unverified behavior, so existing daemons are still
torn down; every record written after this change is verified.

Signed-off-by: Iaroslav Geraskin <iaroslav@reflection.ai>
@iaroslav-reflection

Copy link
Copy Markdown
Contributor Author

@imeoer @Zephyrcf PTAL

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 64.58333% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 26.39%. Comparing base (e4f05cd) to head (f23d306).
⚠️ Report is 6 commits behind head on main.

Files with missing lines Patch % Lines
pkg/manager/daemon_adaptor.go 0.00% 6 Missing ⚠️
pkg/metrics/tool/stat.go 66.66% 2 Missing and 2 partials ⚠️
pkg/manager/manager.go 0.00% 3 Missing ⚠️
pkg/daemon/daemon.go 92.00% 2 Missing ⚠️
pkg/manager/daemon_event.go 0.00% 1 Missing ⚠️
pkg/system/system.go 0.00% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #789      +/-   ##
==========================================
+ Coverage   25.80%   26.39%   +0.58%     
==========================================
  Files         134      134              
  Lines       12860    12912      +52     
==========================================
+ Hits         3319     3408      +89     
+ Misses       9130     9088      -42     
- Partials      411      416       +5     
Files with missing lines Coverage Δ
pkg/manager/daemon_event.go 0.00% <0.00%> (ø)
pkg/system/system.go 5.20% <0.00%> (ø)
pkg/daemon/daemon.go 24.01% <92.00%> (+5.88%) ⬆️
pkg/manager/manager.go 0.00% <0.00%> (ø)
pkg/metrics/tool/stat.go 20.31% <66.66%> (+10.69%) ⬆️
pkg/manager/daemon_adaptor.go 0.00% <0.00%> (ø)

... and 3 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@imeoer

imeoer commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

RecordProcess doesn't reset ProcessStartTime when reading /proc fails:

func (d *Daemon) RecordProcess(pid int) {
	d.States.ProcessID = pid
	startTime, err := tool.GetProcessStartTime(pid)
	if err != nil {
		log.L.WithError(err).Warnf(...)
		return
	}
	d.States.ProcessStartTime = startTime
}

StartDaemon runs again on the same *Daemon from doDaemonRestart/doDaemonFailover. If one of those reads fails (short-lived process, for example), the stale start time from the previous process sticks around. isRecordedProcess() then compares the new pid's start time against that stale value, always returns false, and Terminate() becomes a permanent no-op for that daemon — worse than before the fix. Setting d.States.ProcessStartTime = 0 in the error branch should cover it.

One more thing: Terminate() isn't the only place using the raw pid. CgroupMgr.AddProc(d.States.ProcessID) in StartDaemon and recoverDaemons has real side effects if the pid was recycled, so it's worth guarding there too.

…re acting on it

RecordProcess left the previous process's start time in place when
reading /proc failed. StartDaemon reuses the same Daemon on
restart/failover, so the identity check compared the new PID against
the stale value, always mismatched, and Terminate became a permanent
no-op for that daemon. Reset the start time to zero in that branch so
the record falls back to the unverified pre-check behavior.

Centralize the check into VerifiedPid(), which returns the recorded
PID only after verifying the process identity, and use it everywhere
the snapshotter acts on the process: Terminate and CgroupMgr.AddProc
in StartDaemon and recoverDaemons, where adding a recycled PID to the
cgroup has real side effects on an unrelated process.

Signed-off-by: Iaroslav Geraskin <iaroslav@reflection.ai>
@iaroslav-reflection

Copy link
Copy Markdown
Contributor Author

Thanks! Both fixed. RecordProcess now zeroes the start time when the read fails, so a failed re-record falls back to the grandfather path instead of pinning the stale value — added a regression test for the restart/failover reuse case. For the second point I centralized the check into VerifiedPid() (pid int, ok bool) and made Terminate and both AddProc sites go through it, so future call sites that act on the process have one obvious safe accessor. In recoverDaemons a failed check only skips the cgroup add — the daemon still counts as live since its socket answered.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants