Keep a Save From Being Lost to an Open Reader and Cap the Lock Wait

Windows refuses to replace a file that another process holds open without
FILE_SHARE_DELETE, which is how the C runtime opens files for reading, so
the atomic write could fail against a concurrent reader and drop the save
where the old in-place write had succeeded. Lock the readers that were
still outside the guard, Preset::reload() and the physical printer loader
and writer, and when the rename still fails that way, log it and write in
place as before; losing the save is worse than a torn read. Report the OS
error from a failed write instead of a generic I/O error.

Each leaf guard paid the full two-second wait on its own, so a bulk save
against an instance holding the lock for a long scan stalled once per
preset while holding the preset collection mutex. After a timed-out wait
the same lock file is not waited on again for ten seconds. Read-only scans
never rewrite or delete and many CLI jobs may share one data dir, so they
take no lock and no longer queue behind each other or log about writing.

Take the bundle metadata guard beside the write rather than while the JSON
is built, state the lock-order rule in the header, and give the tests a
temporary file rather than a directory, since the process keeps the lock
file open and a directory holding it cannot be removed on Windows.
This commit is contained in:
Hanif Koh
2026-09-24 16:26:43 +08:00
parent 5181a7fe26
commit 651e48723d
7 changed files with 80 additions and 26 deletions
+19 -8
View File
@@ -20,8 +20,8 @@ using namespace std::chrono_literals;
TEST_CASE("InstanceLock creates its lock file and holds it for the guard's scope", "[InstanceLock]")
{
ScopedTemporaryDir dir;
const std::string path = (dir.path() / "shared.lock").string();
ScopedTemporaryFile lock_file(".lock");
const std::string path = lock_file.string();
{
InstanceLock lock(path);
@@ -37,8 +37,8 @@ TEST_CASE("InstanceLock creates its lock file and holds it for the guard's scope
TEST_CASE("InstanceLock nests within one thread", "[InstanceLock]")
{
ScopedTemporaryDir dir;
const std::string path = (dir.path() / "shared.lock").string();
ScopedTemporaryFile lock_file(".lock");
const std::string path = lock_file.string();
InstanceLock outer(path);
{
@@ -64,8 +64,8 @@ TEST_CASE("InstanceLock is a no-op for an empty path and survives an unwritable
TEST_CASE("InstanceLock serialises the threads of one process", "[InstanceLock]")
{
ScopedTemporaryDir dir;
const std::string path = (dir.path() / "shared.lock").string();
ScopedTemporaryFile lock_file(".lock");
const std::string path = lock_file.string();
std::atomic<bool> holder_ready{false};
std::atomic<bool> holder_released{false};
@@ -93,8 +93,8 @@ TEST_CASE("InstanceLock serialises the threads of one process", "[InstanceLock]"
// Windows through LockFileEx, but spawning a child there is not worth a test.
TEST_CASE("InstanceLock yields to another process and reports it", "[InstanceLock]")
{
ScopedTemporaryDir dir;
const std::string path = (dir.path() / "shared.lock").string();
ScopedTemporaryFile lock_file(".lock");
const std::string path = lock_file.string();
int child_holds[2], child_may_exit[2];
REQUIRE(::pipe(child_holds) == 0);
@@ -122,6 +122,14 @@ TEST_CASE("InstanceLock yields to another process and reports it", "[InstanceLoc
InstanceLock lock(path, 100ms);
locked_while_child_holds = lock.locked();
}
// The timed-out wait starts a cool-down: the next guard does not wait again.
const auto started = std::chrono::steady_clock::now();
bool locked_during_cooldown;
{
InstanceLock lock(path, 5000ms);
locked_during_cooldown = lock.locked();
}
const auto cooldown_wait = std::chrono::steady_clock::now() - started;
REQUIRE(::write(child_may_exit[1], "x", 1) == 1);
int status = 0;
REQUIRE(::waitpid(child, &status, 0) == child);
@@ -129,6 +137,9 @@ TEST_CASE("InstanceLock yields to another process and reports it", "[InstanceLoc
::close(fd);
REQUIRE_FALSE(locked_while_child_holds);
REQUIRE_FALSE(locked_during_cooldown);
REQUIRE(cooldown_wait < 1000ms);
// A guard inside the cool-down still takes the lock when it is free.
InstanceLock lock(path);
REQUIRE(lock.locked());
}