Skip to content

sk-queue: add SCM_PIDFD / SO_PASSPIDFD checkpoint/restore support - #3093

Open
3idey wants to merge 15 commits into
checkpoint-restore:criu-devfrom
3idey:scm-pidfd
Open

sk-queue: add SCM_PIDFD / SO_PASSPIDFD checkpoint/restore support#3093
3idey wants to merge 15 commits into
checkpoint-restore:criu-devfrom
3idey:scm-pidfd

Conversation

@3idey

@3idey 3idey commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

This series adds checkpoint/restore support for the SCM_PIDFD control message and the SO_PASSPIDFD socket option (kernel ≥ 6.5), building on the SCM_CREDENTIALS support merged in #3026.

Approach

SCM_CREDENTIALS and SCM_PIDFD are two recvmsg-time views of the same per-skb state (a refcounted struct pid + creds), so no new packet image format is needed:

  • Dump: if a socket has SO_PASSPIDFD but not SO_PASSCRED, we temporarily enable SO_PASSCRED around the MSG_PEEK loop (same pattern as the SO_PEEK_OFF toggling), so queued pids are dumped through the existing sk_ucred_entry path. The SCM_PIDFD cmsg only carries an fd minted for us at recvmsg time, we inspect it for the sender's liveness/exit status (see below) and close it. SO_PASSPIDFD is saved as a new sk-opts field.
  • Restore: packets are re-sent with spoofed SCM_CREDENTIALS, which attaches the right struct pid to the skb, the restored receiver then mints a correct pidfd on recvmsg for free.

Gated by a kerndat probe and a new so_passpidfd cr-check feature.

Stale pidfds

A pidfd of a reaped process reports fdinfo Pid: -1 and ESRCH on pidfd_send_signal(), so the original pid number is not observable only "a pidfd of a dead process (with a given exit status)" must survive C/R.

  • Definitive detection: on dump we inspect the peeked pidfd directly. A negative SCM_PIDFD payload (kernels < 6.17 refuse to mint a pidfd for a reaped sender) or a PIDFD_GET_INFO/PIDFD_INFO_EXIT-reported exit means the sender is definitively dead, and we record its exit status. A pidfd that proves the sender is alive but outside the dumped tree is dropped rather than mislabeled stale. Kernels without the ioctl fall back to the old pstree-lookup-miss heuristic.
  • Exit-status fidelity: the image stores the sender's exit_code, not just a stale bool. On restore we group one short-lived helper per distinct exit status and have it exit that way, so the restored pidfd reports the original status via PIDFD_GET_INFO not the helper's SIGKILL. Helpers are killed and reaped only after the queue is refilled; each re-sent skb already holds a reference on the helper's struct pid.

PIDFD_GET_INFO support is detected via kerndat and exposed as a pidfd_get_info cr-check feature.

Known limit (documented in the commit messages): on sockets with both options set, a stale packet's SCM_CREDENTIALS view shows the helper's pid; stale packets on SO_PASSCRED-only sockets are still dropped as before.

Tests

  • sk-unix-dgram-pidfd — basic SCM_PIDFD C/R
  • sk-unix-dgram-pidfd-credSO_PASSCRED + SO_PASSPIDFD together, both cmsgs in one recvmsg with matching pids
  • sk-unix-dgram-pidfd-stale — sender reaped before dump; restored pidfd behaves stale (ESRCH, fdinfo Pid: -1) and reports the original exit status on kernels with PIDFD_GET_INFO

Copilot AI review requested due to automatic review settings July 16, 2026 18:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@3idey
3idey force-pushed the scm-pidfd branch 2 times, most recently from 6892d4e to e0860de Compare July 16, 2026 18:52
@3idey

3idey commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

The sk-unix-dgram-pidfd-stale CI failure was a wrong assumption in the test, not in the C/R logic: only kernels >= 6.17 can mint a pidfd for a reaped sender ("af_unix: enable handing out pidfds for reaped tasks in SCM_PIDFD"). Older kernels (CI runs ~6.8) put the pidfd_prepare() error into the cmsg payload the -22 CI saw. Pre-dump recvmsg behaves the same way there, so C/R was already faithful on both kinds of kernels.

Force-pushed: the test now probes the kernel's behavior with a throwaway socketpair before the dump and asserts post-restore behavior matches full stale checks (ESRCH, fdinfo Pid: -1) on >= 6.17, negative cmsg payload on older kernels.

Two known limits of the stale-packet handling worth noting, besides the one in the commit message:

  1. Stale detection infers death from a pstree lookup miss, so a recycled pid number could match the wrong process. Follow-up idea: read fdinfo of the pidfd we already receive (and close) during the dump peek to detect staleness definitively.
  2. On >= 6.17, PIDFD_GET_INFO/PIDFD_INFO_EXIT on the restored stale pidfd reports the helper's SIGKILL instead of the original exit status. Could be fixed by saving the exit info from the peeked pidfd at dump time and having the helper exit the same way.

Happy to do these as follow-ups or fold them into this series, whichever you prefer.

@codecov-commenter

codecov-commenter commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 29.64960% with 261 lines in your changes missing coverage. Please review.
✅ Project coverage is 57.48%. Comparing base (90dd8ae) to head (23cd2ff).

Files with missing lines Patch % Lines
criu/pidfd.c 9.86% 201 Missing ⚠️
criu/sk-queue.c 58.42% 37 Missing ⚠️
criu/kerndat.c 63.63% 12 Missing ⚠️
criu/cr-restore.c 28.57% 5 Missing ⚠️
criu/sockets.c 57.14% 3 Missing ⚠️
criu/cr-check.c 80.00% 2 Missing ⚠️
criu/files.c 0.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##           criu-dev    #3093      +/-   ##
============================================
- Coverage     57.72%   57.48%   -0.24%     
============================================
  Files           161      161              
  Lines         43920    44265     +345     
  Branches       9637     9712      +75     
============================================
+ Hits          25351    25446      +95     
- Misses        18330    18579     +249     
- Partials        239      240       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

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

@adrianreber

Copy link
Copy Markdown
Member

What about this error:

26-07-16T19:13:47.3649648Z ======================= Run zdtm/static/sk-netlink in h ========================
2026-07-16T19:13:47.3649713Z Start test
2026-07-16T19:13:47.3649774Z Test is SUID
2026-07-16T19:13:47.3649921Z ./sk-netlink --pidfile=sk-netlink.pid --outfile=sk-netlink.out
2026-07-16T19:13:47.3649984Z Run criu dump
2026-07-16T19:13:47.3650086Z =[log]=> dump/zdtm/static/sk-netlink/42/1/dump.log
2026-07-16T19:13:47.3650202Z ------------------------ grep Error ------------------------
2026-07-16T19:13:47.3650350Z b'(00.005072) 42 fdinfo 3: pos:                0 flags:           400000/0'
2026-07-16T19:13:47.3650561Z b'(00.005076) Dumping path for 3 fd via self 18 [/home/runner/work/criu/criu/test/zdtm/static]'
2026-07-16T19:13:47.3651128Z b'(00.005085) 42 fdinfo 4: pos:                0 flags:                2/0'
2026-07-16T19:13:47.3651282Z b'(00.005090) sockets: Searching for socket 0x90ea7 family 16'
2026-07-16T19:13:47.3651508Z b"(00.005098) Error (criu/sockets.c:644): sockets: Can't get 1:76 opt: Operation not supported"
2026-07-16T19:13:47.3651595Z b'(00.005102) sockets: No filter for socket'
2026-07-16T19:13:47.3651698Z b'(00.005105) ----------------------------------------'
2026-07-16T19:13:47.3651866Z b'(00.005116) Error (criu/cr-dump.c:1703): Dump files (pid: 42) failed with -1'
2026-07-16T19:13:47.3651939Z b'(00.005435) 42 was stopped'
2026-07-16T19:13:47.3652014Z b'(00.005550) net: Unlock network'
2026-07-16T19:13:47.3652099Z b'(00.005552) Unfreezing tasks into 1'
2026-07-16T19:13:47.3652171Z b'(00.005553) \tUnseizing 42 into 1'
2026-07-16T19:13:47.3652291Z b'(00.005564) Error (criu/cr-dump.c:2135): Dumping FAILED.'
2026-07-16T19:13:47.3652398Z ------------------------ ERROR OVER ------------------------
2026-07-16T19:13:47.3652561Z ################ Test zdtm/static/sk-netlink FAIL at CRIU dump #################
2026-07-16T19:13:47.3652650Z Test output: ================================
2026-07-16T19:13:47.3652654Z 
2026-07-16T19:13:47.3652720Z  <<< ================================
2026-07-16T19:13:47.3652795Z Send the 9 signal to  42
2026-07-16T19:13:47.3652911Z Wait for zdtm/static/sk-netlink(42) to die for 0.100000

I have not seen that one before.

@3idey

3idey commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

What about this error:

26-07-16T19:13:47.3649648Z ======================= Run zdtm/static/sk-netlink in h ========================
2026-07-16T19:13:47.3649713Z Start test
2026-07-16T19:13:47.3649774Z Test is SUID
2026-07-16T19:13:47.3649921Z ./sk-netlink --pidfile=sk-netlink.pid --outfile=sk-netlink.out
2026-07-16T19:13:47.3649984Z Run criu dump
2026-07-16T19:13:47.3650086Z =[log]=> dump/zdtm/static/sk-netlink/42/1/dump.log
2026-07-16T19:13:47.3650202Z ------------------------ grep Error ------------------------
2026-07-16T19:13:47.3650350Z b'(00.005072) 42 fdinfo 3: pos:                0 flags:           400000/0'
2026-07-16T19:13:47.3650561Z b'(00.005076) Dumping path for 3 fd via self 18 [/home/runner/work/criu/criu/test/zdtm/static]'
2026-07-16T19:13:47.3651128Z b'(00.005085) 42 fdinfo 4: pos:                0 flags:                2/0'
2026-07-16T19:13:47.3651282Z b'(00.005090) sockets: Searching for socket 0x90ea7 family 16'
2026-07-16T19:13:47.3651508Z b"(00.005098) Error (criu/sockets.c:644): sockets: Can't get 1:76 opt: Operation not supported"
2026-07-16T19:13:47.3651595Z b'(00.005102) sockets: No filter for socket'
2026-07-16T19:13:47.3651698Z b'(00.005105) ----------------------------------------'
2026-07-16T19:13:47.3651866Z b'(00.005116) Error (criu/cr-dump.c:1703): Dump files (pid: 42) failed with -1'
2026-07-16T19:13:47.3651939Z b'(00.005435) 42 was stopped'
2026-07-16T19:13:47.3652014Z b'(00.005550) net: Unlock network'
2026-07-16T19:13:47.3652099Z b'(00.005552) Unfreezing tasks into 1'
2026-07-16T19:13:47.3652171Z b'(00.005553) \tUnseizing 42 into 1'
2026-07-16T19:13:47.3652291Z b'(00.005564) Error (criu/cr-dump.c:2135): Dumping FAILED.'
2026-07-16T19:13:47.3652398Z ------------------------ ERROR OVER ------------------------
2026-07-16T19:13:47.3652561Z ################ Test zdtm/static/sk-netlink FAIL at CRIU dump #################
2026-07-16T19:13:47.3652650Z Test output: ================================
2026-07-16T19:13:47.3652654Z 
2026-07-16T19:13:47.3652720Z  <<< ================================
2026-07-16T19:13:47.3652795Z Send the 9 signal to  42
2026-07-16T19:13:47.3652911Z Wait for zdtm/static/sk-netlink(42) to die for 0.100000

I have not seen that one before.

Yes, that one is on me, since kernel 6.16 (commit 7d8d93fdde50) SO_PASSPIDFD is restricted to AF_UNIX and returns EOPNOTSUPP on netlink sockets, but I was probing it in the shared unix/netlink branch of dump_socket_opts(). That's why this job was failing. I will make it for AF_UNIX only.

@3idey

3idey commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

@mihalicyn, i think you force pushed by accident:)

@3idey
3idey force-pushed the scm-pidfd branch 2 times, most recently from 448b489 to e330c15 Compare July 22, 2026 13:02
Comment thread criu/sk-queue.c Outdated
@mihalicyn

Copy link
Copy Markdown
Member

i think you force pushed by accident:)

no, it's just because criu-dev branch was force-pushed because of a minor CRIU release

Comment thread images/sk-packet.proto Outdated
@3idey
3idey force-pushed the scm-pidfd branch 2 times, most recently from cf27a31 to 80a6ee6 Compare August 5, 2026 12:22
@3idey
3idey requested a balanced review from Copilot August 6, 2026 21:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@3idey
3idey requested a review from mihalicyn August 6, 2026 21:17
@3idey
3idey force-pushed the scm-pidfd branch 2 times, most recently from c5aaeb8 to b50f4ea Compare August 6, 2026 23:12
Comment thread criu/sk-queue.c Outdated
continue;

if (entry->dead_pid) {
dead_pid = dead_pid_get(&dead_pids, entry->dead_pid);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idea of this code is clear, but not fully correct. You can't lookup dead_pid just from attributes, because you can have two different processes with the same exit code, for instance. And what happens here is that user at the end will get the same pidfd at the end.

A correct way to identify struct pid these days is to rely on inode number (ino), see for instance https://github.com/torvalds/linux/blob/cee9395acd8043be0644b25c34bfa86623f2b935/fs/pidfs.c#L897.

In fact, we already do this in lookup_dead_pidfd function. You can try to do the same and hold a global hashtable using ino as an index, for example.

But please, before changing your code and reworking this, try to come up with an extra zdtm test reproducing the issue I'm describing here theoretically ;-)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I'm on my way to figure it out:)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so what changed is: stand-in processes for dead pids are now keyed on the pidfs inode and kept in one table shared by the whole restore, instead of a table private to each task. So if two tasks hold packets from the same dead sender, they both get the same stand-in and the receivers see one inode, not two. The root task forks them after the tree is up and reaps them once everyone has restored their files.

A task restoring outside the root pid namespace still gets private stand-ins, since a pid from another namespace is not useful to it. Same behaviour as before for that case.

while working I fixed this: a pidfd with no inode recorded no longer shares a stand-in with other such entries, and we now reject an exit status a stand-in cannot actually reproduce instead of silently restoring it as exit(1).

New tests: sk-unix-dgram-pidfd-ino (two senders that died the same way stay distinct) and sk-unix-dgram-pidfd-xtask (one dead sender across two tasks stays one inode).

A queued skb without attached credentials surfaces at recvmsg time as
SCM_CREDENTIALS with pid 0 (and overflow uid/gid) when the receiver has
SO_PASSCRED set. The deferred-write path in sk_queue_post_actions()
searches the process tree for the ucred pid and drops the packet when no
match is found -- which is always the case for pid 0, so such packets
are silently lost on dump.

Write these packets as is instead: there is no pid to translate, and on
restore send_one_pkt() already skips the SCM_CREDENTIALS spoof for a
zero pid, so the packet is re-queued without a forged sender.

Note the remaining fidelity limit: if the receiver's SO_PASS* options
happen to be restored before the packet is re-sent, the kernel attaches
the restorer task's credentials to the skb. This is still strictly
better than dropping the packet altogether.

Signed-off-by: Ahmed Elaidy <elaidya225@gmail.com>
These constants were introduced in kernel 6.5 ("scm: add SO_PASSPIDFD
and SCM_PIDFD") and may be missing from older toolchain headers. Define
them if not provided, next to the existing SO_BUF_LOCK fallback.

Signed-off-by: Ahmed Elaidy <elaidya225@gmail.com>
SO_PASSPIDFD is available since kernel 6.5. Probe it with getsockopt on
an AF_UNIX socket, treating ENOPROTOOPT as "not supported", the same
way SO_BUF_LOCK is detected. Expose the result as the "so_passpidfd"
feature so tests can be gated on it with criu check.

Signed-off-by: Ahmed Elaidy <elaidya225@gmail.com>
Save the SO_PASSPIDFD state of unix sockets in a new so_passpidfd
field of sk_opts_entry and set it back on restore.

The option is dumped for AF_UNIX sockets only: since Linux 6.16,
commit 7d8d93fdde50 ("net: Restrict SO_PASS{CRED,PIDFD,SEC} to
AF_{UNIX,NETLINK,BLUETOOTH}") makes get/setsockopt(SO_PASSPIDFD)
fail with EOPNOTSUPP on anything but unix sockets (SO_PASSCRED
remains valid on netlink), so probing it on netlink sockets would
fail the dump of any task holding one.

Dumping is gated on kernel support detected by kerndat. Restoring is
attempted whenever the image has the bit set, so migrating to a kernel
without SO_PASSPIDFD support fails loudly instead of silently dropping
the option.

Signed-off-by: Ahmed Elaidy <elaidya225@gmail.com>
If a dumped unix socket has SO_PASSPIDFD set, the MSG_PEEK loop in
dump_sk_queue_packet() receives an SCM_PIDFD cmsg for each queued
packet, with a freshly installed pidfd in criu's fd table. Today
dump_packet_cmsg() errors out on any unknown cmsg, so such sockets
cannot be dumped at all (and the fd would be leaked).

The kernel attaches struct pid + creds to the skb at sendmsg time
whenever the receiver has SO_PASSCRED or SO_PASSPIDFD set; SCM_PIDFD
and SCM_CREDENTIALS are just two recvmsg-time views of the same per-skb
state. So instead of converting pidfds back to pids (which would leave
uid/gid unknown), normalize: if the socket has SO_PASSPIDFD but not
SO_PASSCRED, temporarily enable SO_PASSCRED around the peek loop, the
same way SO_PEEK_OFF is toggled there. Every packet then also yields a
genuine SCM_CREDENTIALS cmsg with the true skb pid/uid/gid, which the
existing ucred dump/restore machinery handles unmodified. The SCM_PIDFD
cmsg itself only needs its fd closed.

The toggle is undone on every exit path of dump_sk_queue(), the error
ones included, but not if criu is killed in between: the socket would
then be left with SO_PASSCRED its owner never set and would start
seeing SCM_CREDENTIALS cmsgs it does not expect. SO_PEEK_OFF, toggled
a few lines above, has exactly the same exposure.

No restore-side changes are needed for the queue: the restored packets
are re-sent with a spoofed SCM_CREDENTIALS, which sets the skb pid, and
a receiver with SO_PASSPIDFD restored gets a correct pidfd minted from
it at recvmsg time.

Signed-off-by: Ahmed Elaidy <elaidya225@gmail.com>
The test creates a dgram socketpair, enables SO_PASSPIDFD on the
receiver and queues a message with a plain sendmsg, which makes the
kernel attach the sender pid to the skb. After checkpoint/restore it
verifies that SO_PASSPIDFD is still set and that recvmsg yields an
SCM_PIDFD cmsg with a working pidfd: pidfd_send_signal() accepts it and
its fdinfo Pid matches the sender.

The test is gated on the so_passpidfd feature (kernel >= 6.5).

Signed-off-by: Ahmed Elaidy <elaidya225@gmail.com>
Same as sk-unix-dgram-pidfd, but with both SO_PASSCRED and
SO_PASSPIDFD enabled on the receiver and the message queued with an
explicit SCM_CREDENTIALS cmsg. This exercises the dump path where a
peeked packet carries both SCM_CREDENTIALS and SCM_PIDFD control
messages at once.

After checkpoint/restore the test verifies that a single recvmsg
delivers both cmsgs and that they agree: creds match what was sent and
the pidfd resolves to the same pid as the SCM_CREDENTIALS view.

Signed-off-by: Ahmed Elaidy <elaidya225@gmail.com>
The pidfs PIDFD_GET_INFO ioctl (Linux 6.13) reports what the kernel keeps
about the process a pidfd refers to, including the wait(2)-style exit code
pidfs stashes on its struct pid when it exits. That survives reaping, so it
is the only way to learn how a process we can no longer wait(2) for died.

Add pidfd_query_exit(), a thin wrapper over it. The struct layout is
versioned by size and baked into the ioctl number, and the kernel serves any
request at least as large as the first published version, so we pin our own
copy to that 64-byte version -- exit_code, the only member we need, is its
last field -- rather than track the ones added since. This needs no
<linux/pidfd.h> and works unchanged on every kernel that has the ioctl.

Probe support by issuing it on a pidfd of ourselves: a live process reports
"no exit info" (the ioctl works), while an unsupported kernel fails it.
Expose the result as kdat.has_pidfd_get_info and a "pidfd_get_info" cr-check
feature, so its users and the zdtm tests can tell whether definitive exit
status information is available at all before asking for it.

Signed-off-by: Ahmed Elaidy <elaidya225@gmail.com>
@3idey
3idey force-pushed the scm-pidfd branch 3 times, most recently from 88b6de2 to 23cd2ff Compare September 2, 2026 14:10
@mihalicyn mihalicyn added the GSOC label Sep 2, 2026
CRIU already restores a pidfd whose task was reaped before the dump: the
task is gone, so open_one_pidfd() forks a throwaway process, opens a
pidfd of it and kills it, leaving the restored pidfd as stale as the
original one. What it does not restore is *how* the process died.

The kernel keeps that around. When a task exits, pidfs_exit() stashes
its wait(2)-style exit code in the pidfs attributes hanging off its
struct pid (struct pidfs_attr in fs/pidfs.c), which is what
PIDFD_GET_INFO reports for PIDFD_INFO_EXIT long after the task has been
reaped. A restored pidfd currently reports SIGKILL there -- the way the
throwaway process was disposed of -- no matter how the original died.

So dump that pidfs state alongside the pidfd and reproduce it:

 - dump_one_pidfd() asks pidfd_query_exit() for the exit status of a
   pidfd whose task is already reaped, on kernels that have the ioctl.

 - The image gains pidfs_attr_entry, mirroring struct pidfs_attr. It
   lives in its own images/pidfs.proto rather than inside pidfd.proto
   because a struct pid is shared by everything that refers to the
   process, so its pidfs state is described once and referenced from
   each such image. Only the exit code is modelled: cgroupid is
   meaningless after migration, and the coredump attributes would
   require the stand-in to actually dump core.

 - dead_pid_get()/dead_pid_put_all() replace the open-coded fork-and-
   kill in open_one_pidfd() with a small pool of stand-in processes. A
   stand-in blocks on a pipe until told which wait(2) status to
   reproduce -- exit with a code, or unblock and raise a signal -- and
   is disposed of only once everything that needs to reference the dead
   pid has done so. Stand-ins are shared by exit status, as nothing
   observable distinguishes two dead pids that died the same way. With
   no recorded status (an older kernel, or a pidfd we could not query)
   the previous plain SIGKILL is used.

The SIGCHLD-blocking preamble shared with kill_helper() is factored out
into a small helper, and the error paths in open_one_pidfd() no longer
leak the stand-in process. kill_helper() now unblocks SIGCHLD on the way
out however it got there; it used to return with the signal still
blocked when the kill or the wait failed, which would keep
sigchld_handler() from ever running again in a task that has a whole
restore still ahead of it.

Signed-off-by: Ahmed Elaidy <elaidya225@gmail.com>
pidfd_dead already leaves two pidfds of processes that died differently
-- the child exits with 0, the grandchild is killed with SIGKILL -- so
it is the natural place to check that PIDFD_GET_INFO keeps reporting
how each of them died after C/R.

The check is skipped on kernels without the ioctl (< 6.13), where the
exit status cannot be read before the dump either.

Signed-off-by: Ahmed Elaidy <elaidya225@gmail.com>
A receiver with SO_PASSPIDFD mints a pidfd from the struct pid the
kernel attached to each queued skb. When the sender has already exited
and been reaped, that pidfd is stale, and it must stay stale across C/R.

On dump we already receive an authoritative pidfd for the peeked packet
(recvmsg installs it because SO_PASSCRED is toggled on for the peek), so
inspect it instead of closing it blindly. The cmsg payload is
pidfd_prepare()'s raw return value: -EINVAL (before v6.10) or -ESRCH
means the kernel refused to mint a pidfd for an already reaped task, and
PIDFD_GET_INFO reporting an exit means the same on kernels that do mint
one. Both prove the sender is dead, and its wait(2) exit status is
recorded in the image when known. The remaining errors pidfd_prepare()
can fail with describe the dumping process rather than the sender -- an
exhausted fd table, no memory -- so they abort the dump instead of being
read as a death certificate for a sender that is very much alive. A
sender proven alive yet living outside the dumped tree cannot be
restored faithfully either, so its packet is dropped rather than
mislabelled stale. Kernels without PIDFD_GET_INFO fall back to the
previous pstree-lookup-miss heuristic. The dead sender's pid number is a
dump-host real pid with no meaning after migration, so it is zeroed.

A packet whose SCM_CREDENTIALS pid is zero and for which no pidfd
verdict came back keeps being written as is. A zero pid does not mean
the skb carries no struct pid: scm_set_cred() keeps the struct pid and
derives the number from it with pid_vnr(), which yields 0 for a pid
that cannot be named in the pid namespace we peeked the queue from. The
pidfd is minted from that same struct pid, so a verdict does apply to
such packets, and one that proves the sender dead is handled as stale
above in preference to writing the packet bare.

The peek now also fails the dump on MSG_CTRUNC. The control buffer is
2048 bytes against a worst case of about 1088, so this should not be
reachable; but truncation would drop the SCM_PIDFD cmsg and make a dead
sender look alive, and today it silently loses passed SCM_RIGHTS fds.

On restore, stale packets are re-sent with spoofed SCM_CREDENTIALS
carrying the pid of a stand-in process from the pidfd layer's dead pid
pool, so the receiver mints a pidfd that goes stale as soon as the
stand-in is reaped -- which happens only after the queue is refilled, so
every skb already holds a reference on the stand-in's struct pid. The
pool reproduces the recorded exit status, so PIDFD_GET_INFO on the
restored pidfd reports the way the original sender died; a packet whose
status the kernel could not tell us gets a plain stand-in instead.

Stand-ins are shared by exit status, so two senders that died the same
way end up behind a single one. That collapses their identities: before
the dump a receiver could tell the two apart by the pid in
SCM_CREDENTIALS or by the inode of the pidfds it minted from them, and
afterwards it cannot. Keeping them distinct would need a per-sender key
in the image -- the pidfs inode of the peeked pidfd, which is what
pidfd_entry already records -- and one stand-in per struct pid, shared
with the pidfd file layer. That is left for a follow-up.

Signed-off-by: Ahmed Elaidy <elaidya225@gmail.com>
Queue two datagrams into an SO_PASSPIDFD socket from children that are
reaped before the dump -- one exiting with a code, one killed by a
signal -- so their skb pids are stale and, on kernels that record exit
info, carry distinct statuses (exercising restore's per-status helper
grouping).

Kernel behaviour is probed before the dump on a throwaway socketpair:
only kernels >= 6.17 mint a pidfd for a reaped sender, and PIDFD_GET_INFO
/ PIDFD_INFO_EXIT may or may not report its exit status. After C/R the
test asserts the restored pidfds match that pre-dump behaviour: a
negative cmsg payload on old kernels, or a valid-but-stale pidfd (ESRCH,
fdinfo Pid: -1) whose PIDFD_GET_INFO reports the original exit code /
terminating signal on new ones.

Signed-off-by: Ahmed Elaidy <elaidya225@gmail.com>
A process that was already dead at dump time is restored as a stand-in
process that everything takes its references from, and that is then made
to die the way the original did. Stand-ins were shared by exit status,
which is not an identity: two processes that died the same way collapsed
onto one, so a receiver that could tell them apart before the dump could
not after it.

Key them on the pidfs inode number instead. Since Linux 6.9 pidfs gives
every struct pid a unique inode, and comparing inode numbers is how the
kernel itself tells two pidfds apart; it is the only handle left on a
process that has been reaped. Both sides of the dump read it off a pidfd
the same way, with pidfd_query_ino(): fstat() for i_ino, plus
FS_IOC_GETVERSION for the high half of the pidfs cookie that
pidfs_init_inode() puts in i_generation on a 32-bit kernel.
dump_one_pidfd() prefers that to the ino: line of /proc/pid/fdinfo/N it
used to take, which carries i_ino alone, and dump_packet_cmsg() reads it
off the pidfd it peeks before closing it, recording it in
pidfs_attr_entry. pidfd_entry.ino grows from uint32 to uint64 to hold
it, which is wire compatible: both are varints.

One dead process has to come back as one struct pid however many
references there are, and those references are spread across tasks: two
sockets in two tasks can each hold a packet it sent, and a third task can
hold a pidfd of it. So the stand-ins move from a function-local pool to
one table shared by the whole restore. It is built while the images are
collected, with dead_pid_add() handing out an index per distinct struct
pid, and moved into shared memory before the task tree is forked.

The root task forks one stand-in per entry, once the whole tree exists --
forking earlier would take pid numbers the restored tasks need -- and
before any task starts restoring its files, so the pids are there by the
time anything looks them up. It kills and reaps them once every task has
restored all of its files, which is where the last reference to a dead pid
was taken.

A status helper therefore outlives the open method that forked it, so it
no longer holds the write end of a pipe -- it would sit in the way of a
file still to be restored -- and waits for a signal instead. Stand-ins
also close the fds they inherit and take a PR_SET_PDEATHSIG for the same
reasons.

A task restoring in a pid namespace of its own is the exception: a pid
number of the root task's namespace means nothing there, so it forks
private stand-ins and disposes of them from open_fdinfos(). Two tasks in
different pid namespaces referring to one dead process still get a
stand-in each.

An entry with no inode recorded gets a stand-in of its own, shared with
nothing, rather than one shared by guesswork.

Signed-off-by: Ahmed Elaidy <elaidya225@gmail.com>
A struct pid is identified by its pidfs inode number, not by how the
process died: since Linux 6.9 two pidfds compare equal exactly when
their inode numbers do, and that is the only handle userspace has on
the identity of a process that has already been reaped.

Queue three datagrams into an SO_PASSPIDFD socket from children that
all exit 42 and are reaped before the dump, so nothing but the struct
pid tells their senders apart, and hold a pidfd of the first one. After
C/R the test asserts what the kernel guaranteed before it: the held
pidfd and the packet of that same sender still share a pidfs ino, and
the three senders still have three distinct ones.

The first sender's packet sits at the head of the queue on purpose, so
the held-vs-queued equality is also asserted with MSG_PEEK before the
dump. A throwaway socketpair probes the kernel first: only kernels
>= 6.17 mint a pidfd for a reaped sender, without which there is no
identity to observe and the test skips, and the same probe checks the
kernel itself holds both relations, so a later failure is unambiguously
ours.

Both relations are exactly what keying the stand-ins on the pidfs ino
buys. Sharing them by exit status out of a pool local to each caller,
as the previous commit replaced, gave a pidfd file and a queued packet
of one dead pid two stand-ins, and two senders that died the same way
one.

Signed-off-by: Ahmed Elaidy <elaidya225@gmail.com>
Two tasks each hold a queued packet from the same dead sender, and check
their receivers see one pidfs inode, not one per task.

Signed-off-by: Ahmed Elaidy <elaidya225@gmail.com>
@3idey

3idey commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

@mihalicyn, The CI is keep failing but I don't think this is my pr doing though:)

what i pushed yesterday was stuff i missed but appeared when i took another look:

  • The pidfs cookie is 64 bits but i_ino is a long, so on a 32-bit kernel the high half lives in i_generation. I now read it back with FS_IOC_GETVERSION and fold it in, otherwise two struct pids 2^32 apart would look like one. On 64-bit the generation is 0 so nothing changes there.
  • I also read the ino from the fd itself instead of the ino: line in fdinfo, so a pidfd file and a queued packet are keyed the exact same way and land on the same stand-in.

Plus a fallback for when there is no ino at all, which is every dead sender on a kernel that mints no pidfd for a reaped one. I save the sender's dump host pid and use it only to match ino-less entries against each other. It is not a real identity since pid numbers get reused, but without it every packet of one dead sender would fork its own stand-in. An ino-less entry is never matched against one that has an ino.

If two references claim different exit codes for the same dead pid it warns and keeps the first, since the second guess is no better.

The rest is comments and a cleanup of the pid branches in sk_queue_post_actions(), no behaviour change there.

@3idey
3idey requested a review from mihalicyn September 4, 2026 15:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants