Skip to content

fifo: don't reopen a writer-less non-blocking reader as a closed pipe - #3109

Merged
avagin merged 2 commits into
checkpoint-restore:criu-devfrom
emirbuljubasic:fix/ro-fifo-restore
Sep 2, 2026
Merged

fifo: don't reopen a writer-less non-blocking reader as a closed pipe#3109
avagin merged 2 commits into
checkpoint-restore:criu-devfrom
emirbuljubasic:fix/ro-fifo-restore

Conversation

@emirbuljubasic

@emirbuljubasic emirbuljubasic commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

do_open_fifo() opens a temporary O_RDWR fake writer to unblock the restore, then opens the real descriptor while that writer is still present. For an O_RDONLY|O_NONBLOCK reader, like OpenRC's /run/openrc/init.ctl, this leaves every poll() returning POLLHUP instead of blocking, so an epoll loop spins at 100% CPU.

As far as I can tell, fifo_open() latches pipe->w_counter into filp->f_version only when such an end is opened with no writer around, and pipe_poll() suppresses POLLHUP while the two still match. So the fake writer has to be gone before the real descriptor is opened.

Doing that unconditionally is wrong, as @avagin pointed out: an end whose writer has already been opened and closed is a closed pipe and must keep reporting POLLHUP. The two states seem to differ nowhere but in poll(), so this tells them apart on dump by polling the descriptor drained from the dumpee (it is the dumpee's own struct file, so f_version is intact) and records the answer in a new fifo_entry.wait_writer. Images without the field keep the old behaviour.

Per @rst0git: closing the fake writer drops the last reference to the pipe and takes the just restored pipe size with it, so the pipe parameters are restored on the final descriptor in that case.

The test covers both states. It needs two fifos, since w_counter is per inode and a writer opened on one end hangs up every reader of that fifo. I checked that both assertions actually catch something: with the fake writer always kept the test fails on the waiting end, and with it always dropped it fails on @avagin's closed pipe reproducer. fifo, fifo_ro, fifo_wronly and fifo-ghost still pass.

@codecov-commenter

codecov-commenter commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 30.55556% with 25 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (criu-dev@5d8b3db). Learn more about missing BASE report.
⚠️ Report is 46 commits behind head on criu-dev.

Files with missing lines Patch % Lines
criu/fifo.c 30.55% 25 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##             criu-dev    #3109   +/-   ##
===========================================
  Coverage            ?   57.75%           
===========================================
  Files               ?      161           
  Lines               ?    43951           
  Branches            ?     9644           
===========================================
  Hits                ?    25386           
  Misses              ?    18326           
  Partials            ?      239           

☔ 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

The CentOS Stream 10 CI failures are unrelated. Looks like the iptables based network locking is not working. On CentOS Stream 10 we should switch to nftables. Let me do that.

Comment thread criu/fifo.c Outdated
@mihalicyn
mihalicyn self-requested a review July 28, 2026 08:17
Comment thread criu/fifo.c Outdated
@avagin

avagin commented Jul 29, 2026

Copy link
Copy Markdown
Member

LGTM. Usually, we put test and criu changes into separate patches.

@emirbuljubasic

Copy link
Copy Markdown
Contributor Author

LGTM. Usually, we put test and criu changes into separate patches.

I split it into two commits.

Comment thread criu/fifo.c Outdated
Comment thread test/zdtm/static/fifo_ro_nonblock.c Outdated
Comment thread test/zdtm/static/fifo_ro_nonblock.c
Comment thread test/zdtm/static/Makefile Outdated
@avagin

avagin commented Jul 30, 2026

Copy link
Copy Markdown
Member

Here is one more issue introduced by this patch. We need to distinguish two cases:

  • a read-only descriptor created before the write one (waiting for the write one).
  • the write end has been closed and the read-only descriptor basically a closed pipe.

Here is a reproducer for the second case:

diff --git a/test/zdtm/static/fifo_ro_nonblock.c b/test/zdtm/static/fifo_ro_nonblock.c
index ab0a5e902faa..711421b58187 100644
--- a/test/zdtm/static/fifo_ro_nonblock.c
+++ b/test/zdtm/static/fifo_ro_nonblock.c
@@ -30,7 +30,8 @@ static int hup(int fd, int timeout)
 
 int main(int argc, char **argv)
 {
-       int fd;
+       int fd, fdw;
+       char buf[1024];
 
        test_init(argc, argv);
 
@@ -57,9 +58,16 @@ int main(int argc, char **argv)
                return 1;
        }
 
+       fdw = open(filename, O_WRONLY);
+       close(fdw);
+
        test_daemon();
        test_waitsig();
 
+       if (read(fd, buf, 1) != 0) {
+               fail("fifo hasn't been closed");
+               return 1;
+       }
        /*
         * After restore poll() must still not report POLLHUP. If it does,
         * CRIU reopened the reader while a (fake) writer was present, and the
@@ -69,9 +77,12 @@ int main(int argc, char **argv)
        case -1:
                fail("poll() failed after restore");
                return 1;
-       case 1:
-               fail("fifo reports POLLHUP after restore -- would busy-loop");
+       case 0:
+               fail("no hup");
                return 1;
+       case 1:
+               //fail("fifo reports POLLHUP after restore -- would busy-loop");
+               break;
        }
 
        if (close(fd) < 0) {

@avagin

avagin commented Aug 12, 2026

Copy link
Copy Markdown
Member

@emirbuljubasic any update?

@emirbuljubasic

Copy link
Copy Markdown
Contributor Author

@emirbuljubasic any update?

Sorry for the delay, I'm on vacation. Will be back on this on monday.

@emirbuljubasic emirbuljubasic changed the title fifo: close fake writer before opening non-blocking reader fifo: don't reopen a writer-less non-blocking reader as a closed pipe Aug 18, 2026
@emirbuljubasic

emirbuljubasic commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Here is one more issue introduced by this patch. We need to distinguish two cases:

* a read-only descriptor created before the write one (waiting for the write one).

* the write end has been closed and the read-only descriptor basically a closed pipe.

Here is a reproducer for the second case:

diff --git a/test/zdtm/static/fifo_ro_nonblock.c b/test/zdtm/static/fifo_ro_nonblock.c
index ab0a5e902faa..711421b58187 100644
--- a/test/zdtm/static/fifo_ro_nonblock.c
+++ b/test/zdtm/static/fifo_ro_nonblock.c
@@ -30,7 +30,8 @@ static int hup(int fd, int timeout)
 
 int main(int argc, char **argv)
 {
-       int fd;
+       int fd, fdw;
+       char buf[1024];
 
        test_init(argc, argv);
 
@@ -57,9 +58,16 @@ int main(int argc, char **argv)
                return 1;
        }
 
+       fdw = open(filename, O_WRONLY);
+       close(fdw);
+
        test_daemon();
        test_waitsig();
 
+       if (read(fd, buf, 1) != 0) {
+               fail("fifo hasn't been closed");
+               return 1;
+       }
        /*
         * After restore poll() must still not report POLLHUP. If it does,
         * CRIU reopened the reader while a (fake) writer was present, and the
@@ -69,9 +77,12 @@ int main(int argc, char **argv)
        case -1:
                fail("poll() failed after restore");
                return 1;
-       case 1:
-               fail("fifo reports POLLHUP after restore -- would busy-loop");
+       case 0:
+               fail("no hup");
                return 1;
+       case 1:
+               //fail("fifo reports POLLHUP after restore -- would busy-loop");
+               break;
        }
 
        if (close(fd) < 0) {

@avagin Thanks, I completely missed that case. I've tried to handle it by telling the two apart on dump. The only difference I could find is in poll(), so I poll the descriptor drained from the dumpee (its own struct file, so f_version is still intact) and store the answer in a new fifo_entry.wait_writer. Restore only drops the fake writer when it's set, and images without he field keep the old behaviour.

I wasn't sure about adding a field to the image just for this, so if there's a better place to get this from, or you'd prefer a different approach entirely, I'm happy to redo it.

Your reproducer is in the test now, as a second fifo, since w_counter is per inode and the writer would otherwise hang up the first reader too. One thing I noticed while writing it: read() seems to return 0 in both cases, so I used the poll() check as the actual assertion. Let me know if I've got that wrong.

Thank you everybody for being patient with me and for the informative comments. This is very much outside of my comfort zone.

@avagin avagin self-assigned this Aug 20, 2026
@avagin
avagin requested a lite review from Copilot August 20, 2026 17:23

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.

Pull request overview

This PR fixes a FIFO restore corner case where an O_RDONLY|O_NONBLOCK reader that was originally opened without any writer can be restored in a way that makes poll() report POLLHUP continuously (causing busy-looping in epoll/poll driven daemons). It does so by recording, at dump time, whether the reader should “wait for a writer” (i.e., should not report POLLHUP) and then using that information to decide when to drop CRIU’s temporary fake writer during restore.

Changes:

  • Add a new fifo_entry.wait_writer image field and populate it at dump time via a poll() probe on read-only FIFO fds.
  • Adjust FIFO restore logic to optionally close the temporary O_RDWR “fake writer” before opening the real O_RDONLY|O_NONBLOCK fd (only for the “wait writer” state and only when no buffered FIFO data needs preserving).
  • Add a new ZDTM test (fifo_ro_nonblock) covering both “wait for writer” and “closed pipe” FIFO poll semantics, and verifying pipe size restoration.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
test/zdtm/static/Makefile Adds the new static ZDTM test to the build/run set.
test/zdtm/static/fifo_ro_nonblock.c New regression test for POLLHUP behavior across C/R for `O_RDONLY
images/fifo.proto Extends FIFO image schema with wait_writer to preserve poll semantics.
criu/fifo.c Dumps wait_writer state and updates restore to drop the fake writer early only when appropriate.
Suppressed comments (1)

criu/fifo.c:188

  • Same as above: add braces around the outer if guarding restore_pipe_data() so the control flow is unambiguous and resistant to accidental future edits.
	if (info->restore_data && drop_writer_early)
		if (restore_pipe_data(CR_FD_FIFO_DATA, new_fifo, info->fe->pipe_id, pd_hash_fifo)) {
			close(new_fifo);
			new_fifo = -1;
		}

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread criu/fifo.c Outdated
Comment thread criu/fifo.c Outdated
do_open_fifo() opens a fake O_RDWR writer to unblock the restore, then
opens the real descriptor while that writer is still present. For an
O_RDONLY|O_NONBLOCK reader this caused it to return POLLHUP on every
call indefinitely instead of blocking. In the case of epoll this would
cause it to fire constantly, pinning the CPU at 100%.

The kernel suppresses POLLHUP for a non-blocking read-only end only
when it is opened with no writer around (fifo_open() latches w_counter
into f_version), so no writer may be around when the real descriptor is
opened. That is not always the right thing to do though: a read end
whose writer has been opened and closed already is a closed pipe and
has to keep reporting POLLHUP. Both ends look exactly the same in
/proc, so tell them apart on dump by polling the descriptor drained
from the dumpee -- POLLHUP there means the writer is gone for good --
and store the result in fifo_entry.wait_writer.

Such an end is non-blocking, so it needs no fake writer to open in the
first place: skip it entirely rather than open and close it. Opening
one would also bump w_counter and hang up the ends restored before it,
which matters as a fifo can have more than one read-only end waiting
for a writer. Fifos with buffered data, blocking readers, write ends
and the closed-pipe case keep the fake writer and are unchanged.
Images without wait_writer keep the old behaviour as well.

With no fake writer there is nothing else holding the pipe, so the pipe
parameters are restored on the final descriptor instead. F_SETPIPE_SZ
works on a read-only descriptor and the fifo carries no data here, so
nothing else is lost.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Emir Buljubasic <emirbuljubasic329@gmail.com>
Check that both states of an O_RDONLY|O_NONBLOCK fifo end survive C/R:
one opened with no writer around must not report POLLHUP, and one whose
writer has been opened and closed must keep reporting it. Two fifos are
needed as w_counter is per inode, so a writer on one end affects every
reader of the same fifo.

A second end waiting on the first fifo covers the case of more than one
read-only end waiting for a writer, where restoring one of them must
not hang up the others.

Also check that the pipe size survives the restore, as the reader is
reopened against a freshly created pipe object in the first case.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Emir Buljubasic <emirbuljubasic329@gmail.com>
@avagin
avagin merged commit fa72147 into checkpoint-restore:criu-dev Sep 2, 2026
63 of 64 checks passed
@avagin
avagin requested a lite review from Copilot September 2, 2026 18:07

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.

🟡 Changes recommended

The new wait_writer detection/metadata has a couple of correctness/semantic issues (notably POLLERR handling and documenting/recording wait_writer for blocking readers) that should be fixed to avoid misclassification.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

criu/fifo.c:101

  • dump_one_fifo() sets fifo_entry.wait_writer for all O_RDONLY fds, but the surrounding comment describes the kernel behavior specifically for non-blocking readers. Since the restore-side logic only consults wait_writer for O_NONBLOCK anyway, limiting the field to O_RDONLY|O_NONBLOCK keeps the image semantics aligned with the documentation and avoids recording a potentially misleading value for blocking readers.
	if ((p->flags & O_ACCMODE) == O_RDONLY) {
		int wait_writer = fifo_wait_writer(lfd);

		if (wait_writer < 0)
			return -1;

		e.has_wait_writer = true;
		e.wait_writer = wait_writer;
	}
  • Files reviewed: 4/4 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread criu/fifo.c
return -1;
}

return !(ret > 0 && (pfd.revents & POLLHUP));
Comment thread images/fifo.proto
Comment on lines +9 to +12
/*
* Set for a read-only end which has not seen a writer yet, i.e.
* the one poll() doesn't report POLLHUP for.
*/
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.

6 participants