Ahmed Elaidy

Checkpointing a message from a process that no longer exists

How I taught CRIU to checkpoint and restore socket credentials and pidfds, including the case where the sender is already dead. Google Summer of Code 2026.

CRIU freezes a running program to disk and brings it back later, and the deal it makes with you is that the program cannot tell the difference. Every file descriptor, every socket, every byte of memory comes back the way it was.

So here is a question. A process opens a Unix socket, sends a message, then exits and gets reaped. The message is still sitting in the receiver’s queue, unread. Attached to it, invisibly, is the identity of the sender. Now checkpoint the receiver and restore it tomorrow, on another machine.

What does the receiver see when it finally reads that message?

Before this summer, the answer was: nothing. The identity was quietly dropped. My GSoC project was to fix that, and the dead sender turned out to be the most interesting part of it.

Ancillary data, and why it is awkward

Unix sockets can carry more than a payload. Alongside the bytes, the kernel can attach control messages:

Anything doing authentication over a Unix socket relies on these. systemd and D-Bus both do. The point of the credentials is that the kernel vouches for them, not the sender, so they cannot be forged.

And that is exactly what makes them awkward for CRIU. The sender never stores this data and the receiver never receives it as bytes. The kernel attaches it at sendmsg() time, reading it out of the sending task itself, and hands it over at recvmsg() time. There is no field to copy. CRIU has to read what the kernel attached, describe it in the image, and then persuade the kernel to attach the same thing again on restore.

Part one: credentials

A working implementation for SCM_CREDENTIALS already existed, in the OpenVZ fork of CRIU, written over several years by Pavel Tikhomirov, Alexander Mikhalitsyn and Cyrill Gorcunov. It had never made it upstream.

So my first job was less glamorous than writing new code: port it, understand it, and get it into shape for review. I cherry-picked with -x so the original authorship stayed intact, stripped the OpenVZ-specific bits, fixed some dead netlink-address code the port uncovered, and added a zdtm test of my own.

That series is merged. What matters for the rest of the story is the shape of the solution, because everything after it reuses the same trick:

CRIU is running as root during restore, so it is allowed to name a pid that is not its own. The credentials in the restored packet are not faked in any meaningful sense. The kernel still vouches for them. It just vouches for a process we picked.

Part two: pidfds, and one nice realisation

SCM_PIDFD looks harder. A pidfd is a file descriptor, and CRIU has a whole machinery for saving and restoring those. Saving a pidfd that refers to a process which may not exist on the restore host sounds unpleasant.

Then I read what the kernel actually stores, and it turned out to be much nicer than that.

The kernel does not keep a pidfd on the packet. It keeps a reference to the sender’s struct pid, and mints a fresh pidfd from it, per receiver, at recvmsg() time. SCM_CREDENTIALS and SCM_PIDFD are just two views of the same per-skb state.

Which means CRIU never has to save or restore a pidfd here at all. If restore can get the right struct pid back onto the packet, the receiver mints its own correct pidfd afterwards, exactly as it would have before the dump. And getting a struct pid onto a packet is precisely what the spoofed SCM_CREDENTIALS path already does.

So the whole feature came down to teaching the dump path not to choke on the new control message, closing the pidfd that recvmsg() installs during the peek, and making sure SO_PASSPIDFD itself is saved and restored. A satisfying amount of “this already works” for a feature that sounded like it would not.

That is the easy case, where the sender is still alive. Most of my summer went into the other one.

Part three: the sender is already dead

Go back to the opening question. A process sends a message, exits, and is reaped before anyone reads it.

The packet still holds a reference to its struct pid, so the receiver still gets a pidfd. Just a stale one, and a stale pidfd is not useless. It is very specifically informative:

That last one surprised me. When a task exits, pidfs stashes its wait-status in the attributes hanging off its struct pid (struct pidfs_attr, in fs/pidfs.c), and that outlives reaping. The process is gone, its pid is gone, and the kernel will still tell you it exited with 42.

Applications read all of this. A restore that loses it is not a restore.

But there is nothing left to point at. No process, no pid, no task struct. Just a recorded fact about something that used to exist.

Standing in for the dead

The solution ended up being slightly absurd and I am quite fond of it.

Three-stage diagram. Before the dump: a sender has exited and been reaped, but the message it sent is still queued; the skb holds the data and a struct pid, which points at pidfs recording exit_code 42. Dump: CRIU peeks the queue with SO_PASSCRED on, taking SCM_CREDENTIALS into a ucred, and SCM_PIDFD through PIDFD_GET_INFO into a dead_pid carrying exit_code 42. Restore: CRIU forks a stand-in with pid 4021, sends every packet with spoofed SCM_CREDENTIALS naming it so the kernel pins its struct pid to each skb, and only then lets the stand-in exit 42 and be reaped, so recvmsg yields a stale pidfd reporting ESRCH, Pid: -1 and exit_code 42.
The dump and restore path for a message whose sender has already died.

On dump, the pidfd we peeked tells us whether the sender is still alive. If it is gone, we ask PIDFD_GET_INFO for its exit status and write that into the image, in a small message mirroring the kernel’s own pidfs attributes.

On restore, we fork a short-lived stand-in process. We name it in the spoofed credentials, so the kernel pins its struct pid to the packet. Then, once the whole queue has been refilled, we tell that stand-in to die exactly the way the original sender did: exit with the same code, or raise the same signal.

By the time the restored application looks at the queue, the stand-in has been reaped. Every packet still holds a reference to its struct pid. The pidfd the receiver mints is stale, ESRCH on signalling, Pid: -1 in fdinfo, and PIDFD_GET_INFO reports the original exit status.

The ordering is the whole trick. The stand-in has to be alive while the packets are being queued, because you cannot name a pid that does not exist, and dead before the application looks. So it is disposed of after the loop, never inside it.

There are two details worth mentioning because they cost me real time.

The stand-in waits on a pipe for its instructions rather than being killed from outside, because killing from outside can only ever produce “died by signal”. To reproduce “exited with 42” the process has to exit with 42 itself.

And CRIU runs restore with signals blocked, which a forked child inherits. So a stand-in asked to die by SIGUSR1 has to unblock it first, or raise() merely marks it pending and the child falls out the bottom of the function with the wrong status. That one was silent, and it took a while to find.

Stand-ins are shared by exit status, so a hundred senders that all exited the same way cost one helper process rather than a hundred.

A nice bonus fell out of this. CRIU already restored pidfds of processes that died before the dump, by forking a placeholder and SIGKILLing it. Which meant PIDFD_GET_INFO on a restored pidfd always reported SIGKILL, no matter how the process had really died. Routing that through the same mechanism fixed a bug nobody had filed.

Kernel archaeology

CRIU has to work across a wide range of kernels, and SCM_PIDFD has been busy. Being precise about what each version does turned out to be most of the work.

The best example: when the sender is already reaped, what does the kernel put in the control message?

Kernel What you get for a reaped sender
up to 6.9 -EINVAL in the payload
6.10 to 6.16 -ESRCH in the payload
6.17 onward a real, stale pidfd

That last row is thanks to my mentor’s own PIDFD_STALE work landing in 6.17, which was a slightly surreal thing to depend on while being reviewed by him.

The important part is what is not in that table. The payload is the raw return value of pidfd_prepare(), and it can also be -EMFILE or -ENOMEM. Those say nothing about the sender. They say CRIU’s own fd table filled up, or the allocation failed, in the middle of the peek.

My first version treated any negative value as “the sender is dead”. Under fd pressure that would have substituted a stand-in corpse for a process that was alive and well, and the restored program would have been quietly, confidently wrong. Now exactly two values mean death and everything else aborts the dump.

There was a similar trap on the pid number itself. The kernel derives it with pid_vnr(), which returns 0 when the pid cannot be named in the namespace you are peeking from, while keeping the struct pid perfectly valid. So “pid is 0” does not mean “no sender information”, and code that assumed it did was throwing away recoverable state.

Both of these are the same lesson in different clothes. A checkpoint that fails is annoying. A checkpoint that succeeds and is subtly wrong is much worse, so “I think it works” was rarely good enough.

What review changed

I sent the series, and my mentor asked for two structural changes rather than patches.

First, I had put the exit status inside the socket packet image, next to the credentials. But exit status is not a property of a packet. It is a property of the struct pid, and it is the same struct pid that a plain pidfd file refers to. So it moved into its own image, described once and referenced from both places.

Second, I was forking stand-in processes from the socket code. Dead pidfds are not a socket concept, they exist on their own. So the stand-in pool moved into the pidfd layer where both users share it, and the socket code stopped forking anything at all.

Both times the result was smaller and easier to explain than what I had written. Both times it was obvious in hindsight and not at all obvious while writing it. That is the part of GSoC I would not have got from working alone.

Thanks to Alexander Mikhalitsyn for the weekly syncs and for reviews that kept sending me back to restructure things. That was the best part.


Code: PR #3026 and PR #3093 in checkpoint-restore/criu.

All writing