Hold the POSIX Lock With flock and Leave a Moved-Aside File to the User

An fcntl lock belongs to the process and goes with the first close of
any other descriptor to the lock file, so a backup or an export walking
the data dir could drop the guard's lock without a trace. On POSIX the
guard holds a flock on its own open file instead, which nothing else in
the process can release; Windows keeps LockFileEx.

The Windows write probe opened the target for writing and took a
sharing violation for a refusal, so a file another process merely held
open was not saved at all; only a real denial refuses now, since the
rename that follows moves an open destination aside. A file moved aside
by a refused rename may be the last copy of a file or a file since
deleted on purpose, so the sweep logs it and leaves it to the user
rather than removing or restoring it. The sweep throttles itself per
directory, so a load followed by a save reads each directory once, and
the identity check on the lock file runs at most once a second, so a
scan of hundreds of presets pays for it once. A config that stays
unwritable backs off for longer with each failure in a row, and its
backup copy is written after the config, never before. The Windows
identity helper is shared with the rename that already computed it, and
the header comment that had lost its indentation and its neighbour's
description is whole again.
This commit is contained in:
Hanif Koh
2026-09-25 00:27:44 +08:00
parent a4c925a445
commit 5603ed66c0
8 changed files with 130 additions and 83 deletions
+10 -12
View File
@@ -187,17 +187,14 @@ TEST_CASE("write_file_atomically survives two threads writing one target", "[uti
REQUIRE(temporaries == 0);
}
TEST_CASE("remove_stale_temp_files puts back a moved-aside file whose original is missing", "[utils]") {
TEST_CASE("remove_stale_temp_files leaves a moved-aside file to the user", "[utils]") {
ScopedTemporaryDir dir;
REQUIRE_FALSE(write_file_atomically((dir.path() / "lost.json.4242.old").string(), "last copy"));
REQUIRE_FALSE(write_file_atomically((dir.path() / "lost.json.4242.old").string(), "maybe the last copy"));
boost::filesystem::last_write_time(dir.path() / "lost.json.4242.old", std::time(nullptr) - 7200);
REQUIRE(remove_stale_temp_files(dir.path()) == 0);
REQUIRE(boost::filesystem::exists(dir.path() / "lost.json"));
REQUIRE_FALSE(boost::filesystem::exists(dir.path() / "lost.json.4242.old"));
std::string content;
load_string_file(dir.path() / "lost.json", content);
REQUIRE(content == "last copy");
REQUIRE(boost::filesystem::exists(dir.path() / "lost.json.4242.old"));
REQUIRE_FALSE(boost::filesystem::exists(dir.path() / "lost.json"));
}
TEST_CASE("remove_stale_temp_files removes only old <name>.<pid>.<n>.tmp files", "[utils]") {
@@ -211,22 +208,23 @@ TEST_CASE("remove_stale_temp_files removes only old <name>.<pid>.<n>.tmp files",
// Just written: possibly another instance's in-flight save, so it stays.
REQUIRE_FALSE(write_file_atomically((dir.path() / "g.json.7.2.tmp").string(), "x"));
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") == 2);
SECTION("with a name prefix only matching names go; a numbered backup, a one-segment name or an .old is not removed") {
REQUIRE(remove_stale_temp_files(dir.path(), "a.json") == 1);
REQUIRE_FALSE(boost::filesystem::exists(dir.path() / "a.json.123.7.tmp"));
REQUIRE_FALSE(boost::filesystem::exists(dir.path() / "a.json.123.old"));
REQUIRE(boost::filesystem::exists(dir.path() / "a.json.123.old"));
REQUIRE(boost::filesystem::exists(dir.path() / "a.json.99"));
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 == 9);
REQUIRE(entries == 10);
REQUIRE(boost::filesystem::exists(dir.path() / "a.json.123.old"));
REQUIRE(boost::filesystem::exists(dir.path() / "a.json"));
REQUIRE(boost::filesystem::exists(dir.path() / "h.json.old"));
REQUIRE(boost::filesystem::exists(dir.path() / "a.json.99"));