Lock the Preset Scan Per File and Reopen a Lock File Replaced on Disk

Holding the lock across the whole preset scan meant a reload on a
background thread, which the login path runs, blocked a save on the GUI
thread for the scan's duration through the in-process mutex, which has
no timeout. Each file is locked on its own now, which keeps a file whole
under a reader without keeping the saver waiting.

A guard kept its handle to the lock file for good, so a lock file that
someone deleted or recreated left this instance locking a file no other
instance could see. The guard compares what the path names against what
it opened and reopens when they differ. Preset::save() serialises before
it takes the lock, so the exclusive window is the two file writes.

On Windows an unlocked reader, which the CLI and a timed-out instance
are by design, made the rename fail at once and the write go in place
under that reader; the rename is retried for half a second first, since
a reader is done in milliseconds, and the fallback when no temporary
can be created is logged like the other one. The sweep matches only the
exact <name>.<pid>.<n>.tmp shape and waits an hour, since hosts sharing a
data dir may disagree on the time. Real write access is checked with
access(), the read-only tests skip as root, and the dead permissions
block after the rename is gone.
This commit is contained in:
Hanif Koh
2026-09-24 20:54:02 +08:00
parent 5490320b8b
commit 758b802dec
7 changed files with 149 additions and 56 deletions
+15 -11
View File
@@ -93,6 +93,10 @@ TEST_CASE("write_file_atomically reports a missing directory and writes nothing"
}
TEST_CASE("write_file_atomically refuses a read-only target and leaves it untouched", "[Utils]") {
#ifndef _WIN32
if (::geteuid() == 0)
SKIP("a read-only file does not stop root");
#endif
ScopedTemporaryDir dir;
const boost::filesystem::path target = dir.path() / "pinned.json";
REQUIRE_FALSE(write_file_atomically(target.string(), "pinned"));
@@ -164,33 +168,33 @@ TEST_CASE("write_file_atomically survives two threads writing one target", "[Uti
REQUIRE(temporaries == 0);
}
TEST_CASE("remove_stale_temp_files removes only old <name>.<pid>.tmp files", "[Utils]") {
TEST_CASE("remove_stale_temp_files removes only old <name>.<pid>.<n>.tmp files", "[Utils]") {
ScopedTemporaryDir dir;
for (const char *name : { "a.json.123.tmp", "b.info.4.tmp", "c.json", "d.tmp", "e.json.x.tmp", "f.json..tmp", "a.json.99", "a.json.12.3.tmp" }) {
for (const char *name : { "a.json.123.7.tmp", "b.info.4.0.tmp", "c.json", "d.tmp", "e.json.x.1.tmp", "f.json..tmp", "a.json.99", "a.json.12.tmp" }) {
REQUIRE_FALSE(write_file_atomically((dir.path() / name).string(), "x"));
// An hour old: long past the age below which a temporary may still be in flight.
boost::filesystem::last_write_time(dir.path() / name, std::time(nullptr) - 3600);
}
// Just written: possibly another instance's in-flight save, so it stays.
REQUIRE_FALSE(write_file_atomically((dir.path() / "g.json.7.tmp").string(), "x"));
REQUIRE_FALSE(write_file_atomically((dir.path() / "g.json.7.2.tmp").string(), "x"));
SECTION("with a name prefix only matching names go, and a numbered backup is not a temporary") {
REQUIRE(remove_stale_temp_files(dir.path(), "a.json") == 2);
REQUIRE_FALSE(boost::filesystem::exists(dir.path() / "a.json.123.tmp"));
REQUIRE_FALSE(boost::filesystem::exists(dir.path() / "a.json.12.3.tmp"));
SECTION("with a name prefix only matching names go; a numbered backup or a one-segment name is not a temporary") {
REQUIRE(remove_stale_temp_files(dir.path(), "a.json") == 1);
REQUIRE_FALSE(boost::filesystem::exists(dir.path() / "a.json.123.7.tmp"));
REQUIRE(boost::filesystem::exists(dir.path() / "a.json.99"));
REQUIRE(boost::filesystem::exists(dir.path() / "b.info.4.tmp"));
REQUIRE(boost::filesystem::exists(dir.path() / "a.json.12.tmp"));
REQUIRE(boost::filesystem::exists(dir.path() / "b.info.4.0.tmp"));
}
SECTION("without a prefix every stale temporary goes and nothing else") {
REQUIRE(remove_stale_temp_files(dir.path()) == 3);
REQUIRE(remove_stale_temp_files(dir.path()) == 2);
size_t entries = 0;
for (auto &entry : boost::filesystem::directory_iterator(dir.path())) {
(void) entry;
++entries;
}
REQUIRE(entries == 6);
REQUIRE(entries == 7);
REQUIRE(boost::filesystem::exists(dir.path() / "a.json.99"));
REQUIRE(boost::filesystem::exists(dir.path() / "g.json.7.tmp"));
REQUIRE(boost::filesystem::exists(dir.path() / "g.json.7.2.tmp"));
}
}