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
+24 -8
View File
@@ -711,21 +711,37 @@ std::error_code rename_file(const std::string &from, const std::string &to)
#endif
}
static std::error_code write_whole_file(const std::string &path, const std::string &content)
{
errno = 0;
boost::nowide::ofstream out(path, std::ios::out | std::ios::trunc);
out << content;
out.close();
if (! out.fail())
return {};
return std::make_error_code(errno != 0 ? static_cast<std::errc>(errno) : std::errc::io_error);
}
std::error_code write_file_atomically(const std::string &path, const std::string &content)
{
const std::string tmp_path = path + "." + std::to_string(get_current_pid()) + ".tmp";
{
boost::nowide::ofstream out(tmp_path, std::ios::out | std::ios::trunc);
out << content;
out.close();
if (out.fail()) {
boost::nowide::remove(tmp_path.c_str());
return std::make_error_code(std::errc::io_error);
}
if (std::error_code ec = write_whole_file(tmp_path, content)) {
boost::nowide::remove(tmp_path.c_str());
return ec;
}
std::error_code ec = rename_file(tmp_path, path);
if (ec)
boost::nowide::remove(tmp_path.c_str());
#ifdef _WIN32
// Windows refuses to replace a file another process holds open without
// FILE_SHARE_DELETE, which is how the C runtime opens files for reading.
// Losing the save is worse than a reader seeing a partial file, so write
// in place the way this used to work before the atomic path existed.
if (ec == std::errc::permission_denied) {
BOOST_LOG_TRIVIAL(warning) << "Cannot replace " << path << " while another process holds it open; writing in place";
ec = write_whole_file(path, content);
}
#endif
return ec;
}