mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-05 14:51:06 +00:00
* Lock Config and Preset Files Across Instances and Write Them Atomically Every running instance shares one OrcaSlicer.conf and one user preset tree, and nothing kept their writers apart. Two instances saving at the same moment, or the cloud preset sync thread writing while the GUI thread saved, could interleave, and a reader in another instance could open a preset JSON or .info file between truncate and close and get a partial file, dropping that preset for the session with a parse error. Add InstanceLock, a scoped guard that serialises the threads of one process through a recursive mutex and other processes through an advisory OS file lock: flock on POSIX, held on the guard's own descriptor so no other close in the process can drop it, and LockFileEx on Windows. The outermost guard opens the lock file and closes it on release, so nothing stays open between saves and a data dir can be removed once nothing is saving into it; the file itself is kept, since deleting it would let a third instance lock a fresh file while the second still holds the old one. It is best effort: when the lock file cannot be opened or locked, or another instance still holds it after a second, the guard logs once and lets the write proceed, then leaves the file alone for ten seconds, so a hung instance never blocks every other one and a holder stuck in a debugger does not cost a stall per save. The guard sits at the leaf readers and writers: set_sync_info_and_save() calls save_info() under the preset collection mutex, so a batch lock around save_user_presets() would invert the order against the sync thread. The user preset scan reads its files on worker threads without the guard, since the mutex would serialise them, and takes it per file in the serial commit step, so a save never waits for the whole scan. Each read keeps the bytes of the preset and its .info as they were before parsing; commit compares them with the disk under the guard and reads a file that changed again, so it never deletes or writes back over another instance's newer save, nor installs a .json and .info from two different saves; a preset another instance removed in the meantime is not installed. Without the guard, in a cool-down, the scan still loads the presets but leaves their files alone: an unreadable file stays for the next scan, and a derived compatible printer is not written back. Read-only scans, which is what the CLI does, take no lock and create no lock file. AppConfig holds OrcaSlicer.conf.lock in load() and save(); load is included because the Windows path restores from the .bak copy. Every user preset writer and reader holds user.lock: Preset::save(), which writes no .info when the preset itself could not be written, since an .info without its preset reads as a cloud deletion request, save_info(), reload() and remove_files(), each preset the scan commits, the bundle metadata reads and write, the .info removal after a cloud-confirmed delete, the orphaned-.info scan on the sync thread, the bundle folder removal on unsubscribe and the physical printer writers and delete paths. A bundle import extracts under cache/ into a folder per process and per import, where no scan reads. Preset JSON, .info, bundle metadata, physical printer and config files, and the caches and state files that already used a temporary by hand, now go through write_file_atomically(), which writes <file>.<pid>.<n>.tmp beside the target and renames it over, so a reader that never waits sees a complete old or new file. A symlink is followed; a target that is not a regular file is written in place; and when no temporary can be created beside an existing target, or the rename itself is refused, by a Windows reader holding the file open or a mount that cannot replace in one step, the helper writes in place as before, since losing the save is worse than a torn read. On POSIX the rename replaces the target atomically where the old code removed it first and left a window with no file at all; only a mount that refuses a one-step replace gets the old remove-then-rename. A crash between temporary and rename leaves the temporary behind, which no scan reads. Preset::save() returns whether it wrote the preset, so the scan counts a compatible printer it could not write back as an error. A failed config write keeps the config dirty, and the idle handler waits ten seconds before retrying while an explicit save always tries. * Run the Cross-Process Lock Test on Every Platform The test that checks the guard yields to a lock held elsewhere forked a child to hold it, so it was left out on Windows. The OS lock belongs to the handle on Windows and to the open file description elsewhere, so a second handle in the same process is refused like another instance would be. The test now holds the lock that way and runs everywhere.
166 lines
6.0 KiB
C++
166 lines
6.0 KiB
C++
#include "InstanceLock.hpp"
|
|
|
|
#include <map>
|
|
#include <memory>
|
|
#include <system_error>
|
|
#include <thread>
|
|
|
|
#include <boost/filesystem.hpp>
|
|
#include <boost/log/trivial.hpp>
|
|
#include <boost/nowide/fstream.hpp>
|
|
#ifdef _WIN32
|
|
#include <boost/interprocess/sync/file_lock.hpp>
|
|
#include <boost/nowide/convert.hpp>
|
|
#else
|
|
#include <cerrno>
|
|
#include <fcntl.h>
|
|
#include <sys/file.h>
|
|
#include <unistd.h>
|
|
#endif
|
|
|
|
namespace Slic3r {
|
|
|
|
#ifdef _WIN32
|
|
// LockFileEx, held by this handle alone.
|
|
using NativeFileLock = boost::interprocess::file_lock;
|
|
#else
|
|
// flock(2) rather than an fcntl lock: it belongs to this open file description,
|
|
// so any other code in the process that opens and closes the lock file, as a
|
|
// backup or an export walking the data dir might, cannot drop it. An fcntl
|
|
// lock would go with the first such close.
|
|
class NativeFileLock
|
|
{
|
|
public:
|
|
explicit NativeFileLock(const char *path) : m_fd(::open(path, O_RDWR | O_CREAT | O_CLOEXEC, 0644))
|
|
{
|
|
if (m_fd < 0)
|
|
throw std::system_error(errno, std::generic_category(), path);
|
|
}
|
|
~NativeFileLock() { ::close(m_fd); }
|
|
bool try_lock()
|
|
{
|
|
if (::flock(m_fd, LOCK_EX | LOCK_NB) == 0)
|
|
return true;
|
|
// A signal (a child exiting, for one) interrupts the call like any other; the caller polls again.
|
|
if (errno == EWOULDBLOCK || errno == EINTR)
|
|
return false;
|
|
throw std::system_error(errno, std::generic_category(), "flock");
|
|
}
|
|
void unlock() { ::flock(m_fd, LOCK_UN); }
|
|
private:
|
|
int m_fd;
|
|
};
|
|
#endif
|
|
|
|
// One slot per lock file, shared by every guard in the process: one lock
|
|
// object per path behind a mutex is what makes the guard re-entrant and safe
|
|
// to use from the preset sync thread and the GUI thread at once. The lock
|
|
// file is opened by the outermost guard and closed when it goes, so the file
|
|
// is never held open between guards: whatever is at the path is what gets
|
|
// locked, and a data dir can be removed once nothing is saving into it. The
|
|
// file is kept rather than deleted on release because the lock state lives in
|
|
// the kernel on the open file, and deleting it would let a third instance
|
|
// lock a fresh file while the second still holds the old one.
|
|
struct InstanceLock::Slot
|
|
{
|
|
std::recursive_mutex mutex;
|
|
// Non-null exactly while this process holds the file lock.
|
|
std::unique_ptr<NativeFileLock> file_lock;
|
|
int depth{0};
|
|
// Until this point, after a guard could not open, lock or wait out the
|
|
// file, guards do not touch it.
|
|
std::chrono::steady_clock::time_point cooldown_until{};
|
|
};
|
|
|
|
InstanceLock::Slot &InstanceLock::slot_for(const std::string &lock_file_path)
|
|
{
|
|
// Never freed: a save during static destruction still needs its slot.
|
|
static auto *registry_mutex = new std::mutex();
|
|
static auto *registry = new std::map<std::string, std::unique_ptr<Slot>>();
|
|
|
|
std::lock_guard<std::mutex> guard(*registry_mutex);
|
|
std::unique_ptr<Slot> &slot = (*registry)[lock_file_path];
|
|
if (! slot)
|
|
slot = std::make_unique<Slot>();
|
|
return *slot;
|
|
}
|
|
|
|
// Starts the cool-down. Called with the slot mutex held.
|
|
void InstanceLock::defer(Slot &slot, const std::string &reason)
|
|
{
|
|
slot.cooldown_until = std::chrono::steady_clock::now() + cooldown;
|
|
BOOST_LOG_TRIVIAL(warning) << reason << "; proceeding without the lock for the next " << cooldown.count() << " ms";
|
|
}
|
|
|
|
// Creates the lock file if needed and opens it, or starts the cool-down.
|
|
// Called with the slot mutex held.
|
|
bool InstanceLock::open_lock_file(Slot &slot, const std::string &lock_file_path)
|
|
{
|
|
try {
|
|
#ifdef _WIN32
|
|
// The lock opens an existing file; created once, on the first miss.
|
|
const std::wstring wide_path = boost::nowide::widen(lock_file_path);
|
|
try {
|
|
slot.file_lock = std::make_unique<NativeFileLock>(wide_path.c_str());
|
|
} catch (const std::exception &) {
|
|
boost::nowide::ofstream(lock_file_path, std::ios::app).close();
|
|
slot.file_lock = std::make_unique<NativeFileLock>(wide_path.c_str());
|
|
}
|
|
#else
|
|
slot.file_lock = std::make_unique<NativeFileLock>(lock_file_path.c_str());
|
|
#endif
|
|
return true;
|
|
} catch (const std::exception &e) {
|
|
defer(slot, "Cannot open lock file " + lock_file_path + ": " + e.what() + " (check its owner and permissions)");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
InstanceLock::InstanceLock(const std::string &lock_file_path, std::chrono::milliseconds timeout)
|
|
{
|
|
if (lock_file_path.empty())
|
|
return;
|
|
m_slot = &slot_for(lock_file_path);
|
|
m_slot_guard = std::unique_lock<std::recursive_mutex>(m_slot->mutex);
|
|
const auto now = std::chrono::steady_clock::now();
|
|
if (m_slot->depth == 0 && now >= m_slot->cooldown_until && open_lock_file(*m_slot, lock_file_path)) {
|
|
const auto deadline = now + timeout;
|
|
bool taken = false;
|
|
for (;;) {
|
|
try {
|
|
if ((taken = m_slot->file_lock->try_lock()))
|
|
break;
|
|
} catch (const std::exception &e) {
|
|
defer(*m_slot, "Cannot lock " + lock_file_path + ": " + e.what());
|
|
break;
|
|
}
|
|
if (std::chrono::steady_clock::now() >= deadline) {
|
|
defer(*m_slot, "Another instance has held " + lock_file_path + " for over " + std::to_string(timeout.count()) + " ms");
|
|
break;
|
|
}
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
|
}
|
|
if (! taken)
|
|
m_slot->file_lock.reset();
|
|
}
|
|
// Counted last, so a throw above leaves the slot exactly as it was found.
|
|
++ m_slot->depth;
|
|
m_locked = m_slot->file_lock != nullptr;
|
|
}
|
|
|
|
InstanceLock::~InstanceLock()
|
|
{
|
|
if (m_slot == nullptr)
|
|
return;
|
|
if (-- m_slot->depth == 0 && m_slot->file_lock) {
|
|
try {
|
|
m_slot->file_lock->unlock();
|
|
} catch (const std::exception &e) {
|
|
BOOST_LOG_TRIVIAL(warning) << "Cannot unlock instance lock: " << e.what();
|
|
}
|
|
m_slot->file_lock.reset();
|
|
}
|
|
}
|
|
|
|
} // namespace Slic3r
|