diff --git a/src/fileops.cpp b/src/fileops.cpp index da94370..9cd0db4 100644 --- a/src/fileops.cpp +++ b/src/fileops.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -23,6 +24,7 @@ #include #include #include +#include #include #include @@ -39,6 +41,8 @@ static QList s_undo, s_redo; static QList s_everyone; static constexpr int historyLength = 50; +// How long a copy waits on a pipe with nothing in it before it looks whether it's been asked to stop. +static constexpr int stopCheckMs = 100; // Work under way in this Folio, whichever pane started it. Panes work side // by side, but an undo or redo runs alone, so nothing changes the items it @@ -223,8 +227,23 @@ static bool writeAll(int fd, const char *data, qint64 size) { // it's made with them, so a private file is never open to others while it's // copied. static bool copyFile(const QString &from, const QString &to, FileOps::Job *job) { - QFile in(from), out(to); - if (!in.open(QIODevice::ReadOnly) || !out.open(QIODevice::WriteOnly | QIODevice::NewOnly, in.permissions())) + QFile in, out(to); + const QByteArray name = QFile::encodeName(from); + int source; + // Opened without waiting: a pipe nobody writes to would hold a plain open for good, out of Stop's reach. + while ((source = ::open(name.constData(), O_RDONLY | O_NONBLOCK | O_CLOEXEC)) < 0 && errno == EWOULDBLOCK) { + // Only a file another program holds a lease on is refused for not waiting: it's asked for again until the lease is given up, with a look for Stop between. + if (job && job->cancelled) + return false; + QThread::msleep(stopCheckMs); + } + if (source < 0) + return false; + if (!in.open(source, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) { + ::close(source); + return false; + } + if (!out.open(QIODevice::WriteOnly | QIODevice::NewOnly, in.permissions())) return false; bool kernel = true, ok = true; QByteArray buffer; @@ -245,12 +264,16 @@ static bool copyFile(const QString &from, const QString &to, FileOps::Job *job) continue; } } else { + // A read of a silent pipe comes back at once with nothing, so the wait is here; a file always has its next piece, and a pipe its end once its writers have gone. + pollfd waiting{in.handle(), POLLIN, 0}; + if (::poll(&waiting, 1, stopCheckMs) == 0) + continue; buffer.resize(1 << 20); n = ::read(in.handle(), buffer.data(), buffer.size()); if (n > 0 && !writeAll(out.handle(), buffer.constData(), n)) n = -1; } - if (n < 0 && errno == EINTR) + if (n < 0 && (errno == EINTR || errno == EAGAIN)) continue; if (n <= 0) { ok = n == 0; diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 21e81d0..87169f3 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -61,6 +61,9 @@ }); } +// Longer than a few of a copy's looks for Stop, which come 100 ms apart. +static constexpr int severalStopChecksMs = 300; + // A temporary folder on another drive than QTemporaryDir's, for what has to // cross drives: beside the tests, when they're on another drive. Null when // they aren't. @@ -1847,6 +1850,106 @@ private slots: QVERIFY(ops.busy().isEmpty()); } + void fileOpsCancelStopsACopyWaitingOnAPipe() + { + // Stop ends a copy of a pipe nobody writes to: with no writer at all, then with one that holds the pipe open and says nothing. + QTemporaryDir from, to; + const QByteArray pipe = QFile::encodeName(from.filePath("pipe")); + QVERIFY(mkfifo(pipe.constData(), 0600) == 0); + for (const bool silentWriter : {false, true}) { + const int holder = silentWriter ? open(pipe.constData(), O_RDWR | O_NONBLOCK) : -1; + QCOMPARE(holder >= 0, silentWriter); + FileOps::Job job; + QString error; + QFuture copied = QtConcurrent::run( + [&] { return FileOps::copyRecursively(from.filePath("pipe"), to.filePath("pipe"), &error, false, &job); }); + // A copy Stop didn't reach ends once the pipe is let go, so a run that fails ends too. + const auto finish = qScopeGuard([&] { + const int writer = open(pipe.constData(), O_WRONLY | O_NONBLOCK); + if (writer >= 0) + close(writer); + if (holder >= 0) + close(holder); + copied.waitForFinished(); + }); + // It waits for a writer, rather than taking nothing yet for the pipe's end. + QTest::qWait(severalStopChecksMs); + QVERIFY(!copied.isFinished()); + job.cancelled = true; + QTRY_VERIFY(copied.isFinished()); + QVERIFY(!copied.result()); + QVERIFY2(error.isEmpty(), qPrintable(error)); + QVERIFY(!QFileInfo::exists(to.filePath("pipe"))); + } + } + + void fileOpsCopyLosesNothingWhenAPipesWriterPauses() + { + // A writer that stops for longer than a look for Stop takes is waited for, and what it sends next still arrives. + QTemporaryDir from, to; + const QByteArray pipe = QFile::encodeName(from.filePath("pipe")); + QVERIFY(mkfifo(pipe.constData(), 0600) == 0); + FileOps::Job job; + QString error; + QFuture copied = QtConcurrent::run( + [&] { return FileOps::copyRecursively(from.filePath("pipe"), to.filePath("pipe"), &error, false, &job); }); + int writer = -1; + const auto finish = qScopeGuard([&] { + if (writer >= 0) + close(writer); + copied.waitForFinished(); + }); + QVERIFY(QTest::qWaitFor([&] { return (writer = open(pipe.constData(), O_WRONLY | O_NONBLOCK)) >= 0; })); + QCOMPARE(::write(writer, "first ", 6), 6); + QTest::qWait(severalStopChecksMs); + QVERIFY(!copied.isFinished()); + QCOMPARE(::write(writer, "second", 6), 6); + close(std::exchange(writer, -1)); + QVERIFY(copied.result()); + QVERIFY2(error.isEmpty(), qPrintable(error)); + QCOMPARE(contents(to.filePath("pipe")), QByteArray("first second")); + } + + void fileOpsCopyWaitsForAFileUnderALease() + { + // A file another program holds a write lease on refuses an open that won't wait: the copy waits until the lease is given up, or until Stop. + QTemporaryDir from, to; + write(from.filePath("leased.txt"), "kept"); + // Breaking a lease tells its holder with SIGIO, which would end the test. + const auto before = signal(SIGIO, SIG_IGN); + const auto restored = qScopeGuard([before] { signal(SIGIO, before); }); + for (const bool stopped : {false, true}) { + const int holder = open(QFile::encodeName(from.filePath("leased.txt")).constData(), O_RDWR); + QVERIFY(holder >= 0); + const bool leased = fcntl(holder, F_SETLEASE, F_WRLCK) == 0; + FileOps::Job job; + QString error; + QFuture copied = QtConcurrent::run( + [&] { return FileOps::copyRecursively(from.filePath("leased.txt"), to.filePath("leased.txt"), &error, false, &job); }); + // Closing the holder gives the lease up, so a run that fails ends too. + const auto finish = qScopeGuard([&] { + close(holder); + copied.waitForFinished(); + }); + if (!leased) + QSKIP("This filesystem gives no leases."); + QTest::qWait(severalStopChecksMs); + QVERIFY(!copied.isFinished()); + if (stopped) + job.cancelled = true; + else + QCOMPARE(fcntl(holder, F_SETLEASE, F_UNLCK), 0); + QTRY_VERIFY(copied.isFinished()); + QCOMPARE(copied.result(), !stopped); + QVERIFY2(error.isEmpty(), qPrintable(error)); + QCOMPARE(QFileInfo::exists(to.filePath("leased.txt")), !stopped); + if (!stopped) { + QCOMPARE(contents(to.filePath("leased.txt")), QByteArray("kept")); + QVERIFY(QFile::remove(to.filePath("leased.txt"))); + } + } + } + void fileOpsCancelledMoveAcrossDrivesKeepsOriginals() { // Across drives each original goes once it's copied, so a move