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
+12
View File
@@ -11,6 +11,8 @@
#include <boost/nowide/convert.hpp> #include <boost/nowide/convert.hpp>
#endif #endif
#include "Utils.hpp"
namespace Slic3r { namespace Slic3r {
// One slot per lock file, shared by every guard in the process. A POSIX fcntl // One slot per lock file, shared by every guard in the process. A POSIX fcntl
@@ -22,6 +24,9 @@ struct InstanceLock::Slot
std::recursive_mutex mutex; std::recursive_mutex mutex;
// Absent until the lock file has been created and opened. // Absent until the lock file has been created and opened.
std::unique_ptr<boost::interprocess::file_lock> file_lock; std::unique_ptr<boost::interprocess::file_lock> file_lock;
// What the lock file was when it was opened; a different answer means the
// path was unlinked or replaced and the open handle locks a dead file.
std::string identity;
int depth{0}; int depth{0};
bool file_locked{false}; bool file_locked{false};
// After a timed-out wait, guards skip waiting until this point. // After a timed-out wait, guards skip waiting until this point.
@@ -57,6 +62,7 @@ bool InstanceLock::open_lock_file(Slot &slot, const std::string &lock_file_path)
#else #else
slot.file_lock = std::make_unique<boost::interprocess::file_lock>(lock_file_path.c_str()); slot.file_lock = std::make_unique<boost::interprocess::file_lock>(lock_file_path.c_str());
#endif #endif
slot.identity = file_identity(lock_file_path);
return true; return true;
} catch (const std::exception &e) { } catch (const std::exception &e) {
slot.retry_at = std::chrono::steady_clock::now() + cooldown; slot.retry_at = std::chrono::steady_clock::now() + cooldown;
@@ -73,6 +79,12 @@ InstanceLock::InstanceLock(const std::string &lock_file_path, std::chrono::milli
m_slot = &slot_for(lock_file_path); m_slot = &slot_for(lock_file_path);
m_slot_guard = std::unique_lock<std::recursive_mutex>(m_slot->mutex); m_slot_guard = std::unique_lock<std::recursive_mutex>(m_slot->mutex);
const auto now = std::chrono::steady_clock::now(); const auto now = std::chrono::steady_clock::now();
// A handle to a lock file that was deleted or recreated since it was opened
// would lock a file no other instance can see.
if (m_slot->depth == 0 && m_slot->file_lock && file_identity(lock_file_path) != m_slot->identity) {
BOOST_LOG_TRIVIAL(info) << "Lock file " << lock_file_path << " was replaced; reopening it";
m_slot->file_lock.reset();
}
if (m_slot->depth == 0 && now >= m_slot->retry_at && if (m_slot->depth == 0 && now >= m_slot->retry_at &&
(m_slot->file_lock || open_lock_file(*m_slot, lock_file_path))) { (m_slot->file_lock || open_lock_file(*m_slot, lock_file_path))) {
const bool wait = now >= m_slot->skip_waiting_until; const bool wait = now >= m_slot->skip_waiting_until;
+20 -8
View File
@@ -765,9 +765,14 @@ void Preset::save(DynamicPrintConfig* parent_config)
to_save = &temp_config; to_save = &temp_config;
} }
std::ostringstream json;
to_save->save_to_json(json, bare_name, from_str, this->version.to_string());
InstanceLock instance_lock(user_presets_lock_path()); InstanceLock instance_lock(user_presets_lock_path());
boost::filesystem::create_directories(fs::path(this->file).parent_path()); boost::filesystem::create_directories(fs::path(this->file).parent_path());
to_save->save_to_json(this->file, bare_name, from_str, this->version.to_string()); if (const std::error_code ec = write_file_atomically(this->file, json.str()))
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": failed to save " << this->file << ": " << ec.message();
else
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " save config for: " << this->name << " and filament_id: " << filament_id << " and base_id: " << this->base_id; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " save config for: " << this->name << " and filament_id: " << filament_id << " and base_id: " << this->base_id;
// Bundle presets are synced via bundle_id and don't need individual .info files. // Bundle presets are synced via bundle_id and don't need individual .info files.
@@ -1722,10 +1727,11 @@ void PresetCollection::load_presets(
std::set<std::string> *key_set1 = nullptr, *key_set2 = nullptr; std::set<std::string> *key_set1 = nullptr, *key_set2 = nullptr;
Preset::get_extruder_names_and_keysets(m_type, extruder_id_name, extruder_variant_name, &key_set1, &key_set2); Preset::get_extruder_names_and_keysets(m_type, extruder_id_name, extruder_variant_name, &key_set1, &key_set2);
// Held across the scan so no instance replaces or removes a file mid-read. {
InstanceLock instance_lock(user_presets_lock_path(read_only)); InstanceLock instance_lock(user_presets_lock_path(read_only));
if (instance_lock.locked()) if (instance_lock.locked())
remove_stale_temp_files(dir); remove_stale_temp_files(dir);
}
//BBS: change to json format //BBS: change to json format
for (auto &dir_entry : boost::filesystem::directory_iterator(dir)) for (auto &dir_entry : boost::filesystem::directory_iterator(dir))
{ {
@@ -1747,19 +1753,22 @@ void PresetCollection::load_presets(
preset.file = dir_entry.path().string(); preset.file = dir_entry.path().string();
// Load the preset file, apply preset values on top of defaults. // Load the preset file, apply preset values on top of defaults.
try { try {
DynamicPrintConfig config;
std::map<std::string, std::string> key_values;
std::string reason;
ConfigSubstitutions config_substitutions;
{
// Per file, so no instance replaces or removes it mid-read, and a
// save on another thread never waits for the whole scan.
InstanceLock instance_lock(user_presets_lock_path(read_only));
fs::path idx_path(preset.file); fs::path idx_path(preset.file);
idx_path.replace_extension(".info"); idx_path.replace_extension(".info");
if (fs::exists(idx_path)) { if (fs::exists(idx_path)) {
preset.load_info(idx_path.string()); preset.load_info(idx_path.string());
} }
DynamicPrintConfig config;
//BBS: change to json format //BBS: change to json format
//ConfigSubstitutions config_substitutions = config.load_from_ini(preset.file, substitution_rule); //ConfigSubstitutions config_substitutions = config.load_from_ini(preset.file, substitution_rule);
std::map<std::string, std::string> key_values; config_substitutions = config.load_from_json(preset.file, substitution_rule, key_values, reason);
std::string reason;
ConfigSubstitutions config_substitutions = config.load_from_json(preset.file, substitution_rule, key_values, reason);
if (! config_substitutions.empty())
substitutions.push_back({ preset.name, m_type, PresetConfigSubstitutions::Source::UserFile, preset.file, std::move(config_substitutions) });
if (!reason.empty()) { if (!reason.empty()) {
fs::path file_path(preset.file); fs::path file_path(preset.file);
if (!read_only && fs::exists(file_path)) if (!read_only && fs::exists(file_path))
@@ -1771,6 +1780,9 @@ void PresetCollection::load_presets(
++m_errors; ++m_errors;
continue; continue;
} }
}
if (! config_substitutions.empty())
substitutions.push_back({ preset.name, m_type, PresetConfigSubstitutions::Source::UserFile, preset.file, std::move(config_substitutions) });
std::string version_str = key_values[BBL_JSON_KEY_VERSION]; std::string version_str = key_values[BBL_JSON_KEY_VERSION];
boost::optional<Semver> version = Semver::parse(version_str); boost::optional<Semver> version = Semver::parse(version_str);
+4 -1
View File
@@ -235,9 +235,12 @@ extern std::error_code write_file_atomically(const std::string &path, std::initi
inline std::error_code write_file_atomically(const std::string &path, const std::string &content, bool binary = false) inline std::error_code write_file_atomically(const std::string &path, const std::string &content, bool binary = false)
{ return write_file_atomically(path, { std::string_view(content) }, binary); } { return write_file_atomically(path, { std::string_view(content) }, binary); }
// Remove the `<name>.<pid>.<n>.tmp` files a crashed write_file_atomically() left in // Remove the `<name>.<pid>.<n>.tmp` files a crashed write_file_atomically() left in
// `dir` at least ten minutes ago, only names starting with `name_prefix` when it is // `dir` at least an hour ago, only names starting with `name_prefix` when it is
// given. Meant for directories the application owns. Returns how many were removed. // given. Meant for directories the application owns. Returns how many were removed.
extern size_t remove_stale_temp_files(const boost::filesystem::path &dir, const std::string &name_prefix = std::string()); extern size_t remove_stale_temp_files(const boost::filesystem::path &dir, const std::string &name_prefix = std::string());
// Names the file object behind `path` (device and inode, or volume and file index),
// for noticing that a path was unlinked and recreated. Empty when it cannot be read.
extern std::string file_identity(const std::string &path);
enum CopyFileResult { enum CopyFileResult {
SUCCESS = 0, SUCCESS = 0,
+55 -19
View File
@@ -10,6 +10,8 @@
#include <filesystem> #include <filesystem>
#include <sstream> #include <sstream>
#include <cerrno> #include <cerrno>
#include <chrono>
#include <thread>
#include <cstring> #include <cstring>
#include <iomanip> #include <iomanip>
#include <algorithm> #include <algorithm>
@@ -767,50 +769,84 @@ std::error_code write_file_atomically(const std::string &path, std::initializer_
if (std::error_code ec = write_whole_file(tmp_path, chunks, binary)) { if (std::error_code ec = write_whole_file(tmp_path, chunks, binary)) {
boost::nowide::remove(tmp_path.c_str()); boost::nowide::remove(tmp_path.c_str());
// A directory that allows writing the file but not creating one beside it. // A directory that allows writing the file but not creating one beside it.
if (target_exists && ec == std::errc::permission_denied) if (target_exists && ec == std::errc::permission_denied) {
BOOST_LOG_TRIVIAL(warning) << "Cannot create a temporary beside " << path << " (" << ec.message() << "); writing in place";
return write_whole_file(path, chunks, binary); return write_whole_file(path, chunks, binary);
}
return ec; return ec;
} }
#ifndef _WIN32 #ifndef _WIN32
// On Windows a read-only bit on the temporary would stop the rename itself. // Only here: on Windows the target was refused above unless writable, and
// a read-only bit on the temporary would stop the rename itself.
if (target_exists) if (target_exists)
boost::filesystem::permissions(tmp_path, target.permissions(), bec); boost::filesystem::permissions(tmp_path, target.permissions(), bec);
#endif #endif
std::error_code ec = rename_file(tmp_path, path); // Windows refuses to replace a file another process holds open without
// FILE_SHARE_DELETE, which is how the C runtime opens files for reading; a
// reader is done in milliseconds, so wait it out before giving up on the
// atomic path.
std::error_code ec;
for (int attempt = 0; attempt < 20; ++ attempt) {
ec = rename_file(tmp_path, path);
if (ec != std::errc::permission_denied)
break;
std::this_thread::sleep_for(std::chrono::milliseconds(25));
}
if (ec) { if (ec) {
boost::nowide::remove(tmp_path.c_str()); boost::nowide::remove(tmp_path.c_str());
// Windows refuses to replace a file another process holds open without // Still refused, or a mount that cannot replace a file in one step at
// FILE_SHARE_DELETE, which is how the C runtime opens files for reading, // all. Losing the save is worse than a reader seeing a partial file, so
// and some mounts cannot replace a file in one step at all. Losing the // write in place the way this used to work before the atomic path existed.
// 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.
BOOST_LOG_TRIVIAL(warning) << "Cannot replace " << path << " (" << ec.message() << "); writing in place"; BOOST_LOG_TRIVIAL(warning) << "Cannot replace " << path << " (" << ec.message() << "); writing in place";
return write_whole_file(path, chunks, binary); return write_whole_file(path, chunks, binary);
} }
#ifdef _WIN32
if (target_exists)
boost::filesystem::permissions(path, target.permissions(), bec);
#endif
return {}; return {};
} }
std::string file_identity(const std::string &path)
{
#ifdef _WIN32
const std::wstring wide = boost::nowide::widen(path);
HANDLE handle = ::CreateFileW(wide.c_str(), 0, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, nullptr);
if (handle == INVALID_HANDLE_VALUE)
return {};
BY_HANDLE_FILE_INFORMATION info;
const bool ok = ::GetFileInformationByHandle(handle, &info) != 0;
::CloseHandle(handle);
if (! ok)
return {};
return std::to_string(info.dwVolumeSerialNumber) + ":" + std::to_string((static_cast<uint64_t>(info.nFileIndexHigh) << 32) | info.nFileIndexLow);
#else
struct stat st;
if (::stat(path.c_str(), &st) != 0)
return {};
return std::to_string(static_cast<unsigned long long>(st.st_dev)) + ":" + std::to_string(static_cast<unsigned long long>(st.st_ino));
#endif
}
size_t remove_stale_temp_files(const boost::filesystem::path &dir, const std::string &name_prefix) size_t remove_stale_temp_files(const boost::filesystem::path &dir, const std::string &name_prefix)
{ {
// <name_prefix>...<digits>.tmp // <name_prefix>...<digits>.<digits>.tmp, exactly the shape write_file_atomically() makes.
auto is_temp_name = [&name_prefix](const std::string &name) { auto is_temp_name = [&name_prefix](const std::string &name) {
if (name.compare(0, name_prefix.size(), name_prefix) != 0) if (name.compare(0, name_prefix.size(), name_prefix) != 0)
return false; return false;
static const std::string suffix = ".tmp"; static const std::string suffix = ".tmp";
if (name.size() <= suffix.size() || name.compare(name.size() - suffix.size(), suffix.size(), suffix) != 0) if (name.size() <= suffix.size() || name.compare(name.size() - suffix.size(), suffix.size(), suffix) != 0)
return false; return false;
const size_t digits_end = name.size() - suffix.size(); size_t end = name.size() - suffix.size();
const size_t dot = name.rfind('.', digits_end - 1); for (int segment = 0; segment < 2; ++ segment) {
return dot != std::string::npos && dot != 0 && dot + 1 != digits_end && const size_t dot = name.rfind('.', end - 1);
std::all_of(name.begin() + dot + 1, name.begin() + digits_end, [](char c) { return c >= '0' && c <= '9'; }); if (dot == std::string::npos || dot == 0 || dot + 1 == end ||
! std::all_of(name.begin() + dot + 1, name.begin() + end, [](char c) { return c >= '0' && c <= '9'; }))
return false;
end = dot;
}
return true;
}; };
// An instance that gave up waiting for the lock writes unlocked by design, // An instance that gave up waiting for the lock writes unlocked by design,
// so a temporary this young may still be in flight; a crash leftover is old. // so a temporary this young may still be in flight, and hosts sharing a
constexpr std::time_t stale_age = 10 * 60; // data dir may disagree on the time by minutes; a crash leftover is old.
constexpr std::time_t stale_age = 60 * 60;
const std::time_t now = std::time(nullptr); const std::time_t now = std::time(nullptr);
size_t removed = 0; size_t removed = 0;
boost::system::error_code ec; boost::system::error_code ec;
+18
View File
@@ -96,6 +96,24 @@ TEST_CASE("InstanceLock retries a lock file it could not open once the cool-down
REQUIRE(after_cooldown); REQUIRE(after_cooldown);
} }
TEST_CASE("InstanceLock reopens a lock file that was replaced on disk", "[InstanceLock]")
{
ScopedTemporaryFile lock_file(".lock");
const std::string path = lock_file.string();
{
InstanceLock lock(path);
REQUIRE(lock.locked());
}
boost::filesystem::remove(path);
InstanceLock lock(path);
REQUIRE(lock.locked());
// Only a reopen recreates the file; a guard still holding the unlinked one
// would leave the path missing. (The inode number itself may be reused once
// the old handle is closed, so it is no proof either way.)
REQUIRE(boost::filesystem::exists(path));
}
TEST_CASE("InstanceLock serialises the threads of one process", "[InstanceLock]") TEST_CASE("InstanceLock serialises the threads of one process", "[InstanceLock]")
{ {
ScopedTemporaryFile lock_file(".lock"); ScopedTemporaryFile lock_file(".lock");
+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]") { 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; ScopedTemporaryDir dir;
const boost::filesystem::path target = dir.path() / "pinned.json"; const boost::filesystem::path target = dir.path() / "pinned.json";
REQUIRE_FALSE(write_file_atomically(target.string(), "pinned")); 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); 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; 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")); 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. // 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); boost::filesystem::last_write_time(dir.path() / name, std::time(nullptr) - 3600);
} }
// Just written: possibly another instance's in-flight save, so it stays. // 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") { 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); REQUIRE(remove_stale_temp_files(dir.path(), "a.json") == 1);
REQUIRE_FALSE(boost::filesystem::exists(dir.path() / "a.json.123.tmp")); REQUIRE_FALSE(boost::filesystem::exists(dir.path() / "a.json.123.7.tmp"));
REQUIRE_FALSE(boost::filesystem::exists(dir.path() / "a.json.12.3.tmp"));
REQUIRE(boost::filesystem::exists(dir.path() / "a.json.99")); 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") { 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; size_t entries = 0;
for (auto &entry : boost::filesystem::directory_iterator(dir.path())) { for (auto &entry : boost::filesystem::directory_iterator(dir.path())) {
(void) entry; (void) entry;
++entries; ++entries;
} }
REQUIRE(entries == 6); REQUIRE(entries == 7);
REQUIRE(boost::filesystem::exists(dir.path() / "a.json.99")); 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"));
} }
} }
+8
View File
@@ -1,5 +1,9 @@
#include <catch2/catch_all.hpp> #include <catch2/catch_all.hpp>
#ifndef _WIN32
#include <unistd.h>
#endif
#include <boost/filesystem.hpp> #include <boost/filesystem.hpp>
#include <boost/crc.hpp> #include <boost/crc.hpp>
#include <cereal/archives/binary.hpp> #include <cereal/archives/binary.hpp>
@@ -1358,6 +1362,10 @@ TEST_CASE("a header claiming more body than the file holds is rejected", "[Vendo
TEST_CASE("a failed write leaves the previous cache in place", "[VendorCache]") TEST_CASE("a failed write leaves the previous cache in place", "[VendorCache]")
{ {
#ifndef _WIN32
if (::geteuid() == 0)
SKIP("a read-only file does not stop root");
#endif
TempDir tmp; TempDir tmp;
const std::string cache = (tmp.path / "Durable.opc").string(); const std::string cache = (tmp.path / "Durable.opc").string();
REQUIRE(save_one_vendor(cache, one_vendor("Durable"), "Durable", "1.0.0")); REQUIRE(save_one_vendor(cache, one_vendor("Durable"), "Durable", "1.0.0"));