mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-04 06:11:02 +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.
229 lines
6.9 KiB
C++
229 lines
6.9 KiB
C++
#include <catch2/catch_all.hpp>
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <thread>
|
|
|
|
#include <boost/filesystem.hpp>
|
|
|
|
#include "libslic3r/InstanceLock.hpp"
|
|
#include "test_utils.hpp"
|
|
|
|
#ifdef _WIN32
|
|
#include <boost/interprocess/sync/file_lock.hpp>
|
|
#include <boost/nowide/convert.hpp>
|
|
#include <boost/nowide/fstream.hpp>
|
|
#else
|
|
#include <fcntl.h>
|
|
#include <sys/file.h>
|
|
#include <unistd.h>
|
|
#endif
|
|
|
|
using namespace Slic3r;
|
|
using namespace std::chrono_literals;
|
|
|
|
// Sets a process-wide knob for one test and restores it however the test ends.
|
|
template<typename T> struct ScopedStaticValue
|
|
{
|
|
T &ref;
|
|
T saved;
|
|
ScopedStaticValue(T &ref, T value) : ref(ref), saved(ref) { ref = value; }
|
|
~ScopedStaticValue() { ref = saved; }
|
|
};
|
|
|
|
TEST_CASE("InstanceLock creates its lock file and holds it for the guard's scope", "[InstanceLock]")
|
|
{
|
|
ScopedTemporaryFile lock_file(".lock");
|
|
const std::string path = lock_file.string();
|
|
|
|
{
|
|
InstanceLock lock(path);
|
|
REQUIRE(lock.locked());
|
|
REQUIRE(boost::filesystem::exists(path));
|
|
}
|
|
// Released: a fresh guard gets the lock at once instead of waiting out a timeout.
|
|
const auto started = std::chrono::steady_clock::now();
|
|
InstanceLock again(path, 5000ms);
|
|
REQUIRE(again.locked());
|
|
// Well inside the timeout it would otherwise have waited out; loose enough for a loaded runner.
|
|
REQUIRE(std::chrono::steady_clock::now() - started < 4000ms);
|
|
}
|
|
|
|
TEST_CASE("InstanceLock nests within one thread", "[InstanceLock]")
|
|
{
|
|
ScopedTemporaryFile lock_file(".lock");
|
|
const std::string path = lock_file.string();
|
|
|
|
InstanceLock outer(path);
|
|
{
|
|
InstanceLock inner(path, 100ms);
|
|
REQUIRE(inner.locked());
|
|
}
|
|
// The inner guard leaving does not release the outer one.
|
|
REQUIRE(outer.locked());
|
|
}
|
|
|
|
TEST_CASE("InstanceLock is a no-op for an empty path and survives an unwritable one", "[InstanceLock]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
|
|
InstanceLock none("");
|
|
REQUIRE_FALSE(none.locked());
|
|
|
|
// The directory does not exist, so the lock file cannot be created; the
|
|
// guard still constructs and the write it guards can go ahead.
|
|
InstanceLock unwritable((dir.path() / "missing" / "shared.lock").string(), 100ms);
|
|
REQUIRE_FALSE(unwritable.locked());
|
|
}
|
|
|
|
TEST_CASE("InstanceLock retries a lock file it could not open once the cool-down passes", "[InstanceLock]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const std::string path = (dir.path() / "later" / "shared.lock").string();
|
|
ScopedStaticValue cooldown(InstanceLock::cooldown, 300ms);
|
|
|
|
bool before_dir, during_cooldown, after_cooldown;
|
|
const auto started = std::chrono::steady_clock::now();
|
|
{
|
|
InstanceLock lock(path, 100ms);
|
|
before_dir = lock.locked();
|
|
}
|
|
boost::filesystem::create_directories(dir.path() / "later");
|
|
{
|
|
InstanceLock lock(path, 100ms);
|
|
during_cooldown = lock.locked();
|
|
}
|
|
const bool second_guard_inside_cooldown = std::chrono::steady_clock::now() - started < InstanceLock::cooldown;
|
|
std::this_thread::sleep_for(400ms);
|
|
{
|
|
InstanceLock lock(path, 100ms);
|
|
after_cooldown = lock.locked();
|
|
}
|
|
|
|
REQUIRE_FALSE(before_dir);
|
|
// A loaded runner may take longer than the cool-down to get here; then the
|
|
// second guard legitimately retried, so only assert when the timing held.
|
|
if (second_guard_inside_cooldown)
|
|
REQUIRE_FALSE(during_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());
|
|
// Each outermost guard opens the file afresh, so the deleted path is back.
|
|
REQUIRE(boost::filesystem::exists(path));
|
|
}
|
|
|
|
TEST_CASE("InstanceLock serialises the threads of one process", "[InstanceLock]")
|
|
{
|
|
ScopedTemporaryFile lock_file(".lock");
|
|
const std::string path = lock_file.string();
|
|
|
|
std::atomic<bool> holder_ready{false};
|
|
std::atomic<bool> holder_released{false};
|
|
std::thread holder([&] {
|
|
InstanceLock lock(path);
|
|
holder_ready = true;
|
|
std::this_thread::sleep_for(150ms);
|
|
holder_released = true;
|
|
});
|
|
while (! holder_ready)
|
|
std::this_thread::yield();
|
|
|
|
bool released_before_acquire = false;
|
|
{
|
|
InstanceLock lock(path);
|
|
released_before_acquire = holder_released;
|
|
}
|
|
holder.join();
|
|
REQUIRE(released_before_acquire);
|
|
}
|
|
|
|
// Holds the OS lock on a file through a handle of its own, as another instance would. The lock
|
|
// belongs to the handle on Windows and to the open file description elsewhere, so the guard's
|
|
// handle is refused while this one holds it.
|
|
class OtherHolder
|
|
{
|
|
public:
|
|
explicit OtherHolder(const std::string &path)
|
|
{
|
|
#ifdef _WIN32
|
|
boost::nowide::ofstream(path, std::ios::app).close();
|
|
m_lock = boost::interprocess::file_lock(boost::nowide::widen(path).c_str());
|
|
m_held = m_lock.try_lock();
|
|
#else
|
|
m_fd = ::open(path.c_str(), O_RDWR | O_CREAT, 0644);
|
|
m_held = m_fd >= 0 && ::flock(m_fd, LOCK_EX | LOCK_NB) == 0;
|
|
#endif
|
|
}
|
|
~OtherHolder() { release(); }
|
|
OtherHolder(const OtherHolder &) = delete;
|
|
OtherHolder &operator=(const OtherHolder &) = delete;
|
|
|
|
bool held() const { return m_held; }
|
|
void release()
|
|
{
|
|
#ifdef _WIN32
|
|
if (m_held)
|
|
m_lock.unlock();
|
|
m_lock = boost::interprocess::file_lock();
|
|
#else
|
|
if (m_fd >= 0)
|
|
::close(m_fd);
|
|
m_fd = -1;
|
|
#endif
|
|
m_held = false;
|
|
}
|
|
|
|
private:
|
|
#ifdef _WIN32
|
|
boost::interprocess::file_lock m_lock;
|
|
#else
|
|
int m_fd{-1};
|
|
#endif
|
|
bool m_held{false};
|
|
};
|
|
|
|
TEST_CASE("InstanceLock yields to a lock held through another handle and reports it", "[InstanceLock]")
|
|
{
|
|
ScopedTemporaryFile lock_file(".lock");
|
|
const std::string path = lock_file.string();
|
|
ScopedStaticValue cooldown(InstanceLock::cooldown, 300ms);
|
|
|
|
OtherHolder other(path);
|
|
REQUIRE(other.held());
|
|
|
|
bool locked_while_other_holds;
|
|
{
|
|
InstanceLock lock(path, 100ms);
|
|
locked_while_other_holds = lock.locked();
|
|
}
|
|
// The timed-out wait starts a cool-down: the next guard does not touch the file.
|
|
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;
|
|
other.release();
|
|
|
|
REQUIRE_FALSE(locked_while_other_holds);
|
|
REQUIRE_FALSE(locked_during_cooldown);
|
|
REQUIRE(cooldown_wait < 4000ms);
|
|
// Once the cool-down passes, the lock the other holder released is taken again.
|
|
std::this_thread::sleep_for(400ms);
|
|
InstanceLock lock(path);
|
|
REQUIRE(lock.locked());
|
|
}
|