mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-26 10:21:00 +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 preset scan re-takes the guard every 32 files rather than holding it across the scan, and a bundle or user folder is renamed into cache/ under the lock and removed outside it, so a save never waits for a whole tree. 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 file read by the preset scan, 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 target this process may not write is refused before anything is written, unless the caller says the file was always replaced, as the config was; a symlink is followed; a target that is not a regular file is written in place; and when 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, which the scans of the directories the application owns remove once it is an hour old; only the exact shapes this code writes qualify, so a user's numbered backup or an export folder is never touched. A failed config write keeps the config dirty, and the idle handler waits ten seconds before retrying while an explicit save always tries.
This commit is contained in:
@@ -8,8 +8,11 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <ctime>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <system_error>
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <unistd.h> // getuid
|
||||
@@ -62,6 +65,192 @@ TEST_CASE("per-user temp root is unchanged on Windows, isolated elsewhere", "[ut
|
||||
#endif
|
||||
}
|
||||
|
||||
TEST_CASE("write_file_atomically replaces the target and leaves no temporary file", "[utils]") {
|
||||
ScopedTemporaryDir dir;
|
||||
const boost::filesystem::path target = dir.path() / "preset.json";
|
||||
|
||||
REQUIRE_FALSE(write_file_atomically(target.string(), "first"));
|
||||
REQUIRE_FALSE(write_file_atomically(target.string(), "second"));
|
||||
|
||||
std::string content;
|
||||
load_string_file(target, content);
|
||||
REQUIRE(content == "second");
|
||||
size_t entries = 0;
|
||||
for (auto &entry : boost::filesystem::directory_iterator(dir.path())) {
|
||||
(void) entry;
|
||||
++entries;
|
||||
}
|
||||
REQUIRE(entries == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("write_file_atomically reports a missing directory and writes nothing", "[utils]") {
|
||||
ScopedTemporaryDir dir;
|
||||
const boost::filesystem::path target = dir.path() / "missing" / "preset.json";
|
||||
|
||||
const std::error_code ec = write_file_atomically(target.string(), "x");
|
||||
REQUIRE(ec == std::errc::no_such_file_or_directory);
|
||||
REQUIRE_FALSE(boost::filesystem::exists(target));
|
||||
}
|
||||
|
||||
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;
|
||||
const boost::filesystem::path target = dir.path() / "pinned.json";
|
||||
REQUIRE_FALSE(write_file_atomically(target.string(), "pinned"));
|
||||
boost::filesystem::permissions(target, boost::filesystem::owner_read | boost::filesystem::group_read | boost::filesystem::others_read);
|
||||
|
||||
const std::error_code ec = write_file_atomically(target.string(), "replaced");
|
||||
boost::filesystem::permissions(target, boost::filesystem::owner_read | boost::filesystem::owner_write | boost::filesystem::group_read | boost::filesystem::others_read);
|
||||
|
||||
REQUIRE(ec == std::errc::permission_denied);
|
||||
std::string content;
|
||||
load_string_file(target, content);
|
||||
REQUIRE(content == "pinned");
|
||||
}
|
||||
|
||||
TEST_CASE("write_file_atomically replaces a read-only target when asked to", "[utils]") {
|
||||
#ifndef _WIN32
|
||||
if (::geteuid() == 0)
|
||||
SKIP("a read-only file does not stop root");
|
||||
#endif
|
||||
ScopedTemporaryDir dir;
|
||||
const boost::filesystem::path target = dir.path() / "pinned.json";
|
||||
REQUIRE_FALSE(write_file_atomically(target.string(), "pinned"));
|
||||
boost::filesystem::permissions(target, boost::filesystem::owner_read | boost::filesystem::group_read | boost::filesystem::others_read);
|
||||
|
||||
const std::error_code ec = write_file_atomically(target.string(), "replaced", { false, /*replace_read_only=*/true });
|
||||
boost::filesystem::permissions(target, boost::filesystem::owner_read | boost::filesystem::owner_write | boost::filesystem::group_read | boost::filesystem::others_read);
|
||||
|
||||
REQUIRE_FALSE(ec);
|
||||
std::string content;
|
||||
load_string_file(target, content);
|
||||
REQUIRE(content == "replaced");
|
||||
}
|
||||
|
||||
TEST_CASE("write_file_atomically keeps bytes intact in binary mode", "[utils]") {
|
||||
ScopedTemporaryDir dir;
|
||||
const boost::filesystem::path target = dir.path() / "blob.bin";
|
||||
const std::string bytes("a\r\nb\0c", 6);
|
||||
|
||||
REQUIRE_FALSE(write_file_atomically(target.string(), bytes, { /*binary=*/true }));
|
||||
REQUIRE(boost::filesystem::file_size(target) == bytes.size());
|
||||
}
|
||||
|
||||
#ifndef _WIN32
|
||||
TEST_CASE("write_file_atomically writes through a symlink and keeps the target's permissions", "[utils]") {
|
||||
ScopedTemporaryDir dir;
|
||||
const boost::filesystem::path real = dir.path() / "real.json";
|
||||
const boost::filesystem::path link = dir.path() / "link.json";
|
||||
REQUIRE_FALSE(write_file_atomically(real.string(), "first"));
|
||||
boost::filesystem::permissions(real, boost::filesystem::owner_read | boost::filesystem::owner_write);
|
||||
boost::filesystem::create_symlink(real, link);
|
||||
|
||||
REQUIRE_FALSE(write_file_atomically(link.string(), "second"));
|
||||
|
||||
REQUIRE(boost::filesystem::is_symlink(boost::filesystem::symlink_status(link)));
|
||||
std::string content;
|
||||
load_string_file(real, content);
|
||||
REQUIRE(content == "second");
|
||||
|
||||
REQUIRE_FALSE(write_file_atomically(real.string(), "third"));
|
||||
const auto perms = boost::filesystem::status(real).permissions() & boost::filesystem::all_all;
|
||||
REQUIRE(perms == (boost::filesystem::owner_read | boost::filesystem::owner_write));
|
||||
}
|
||||
#endif
|
||||
|
||||
TEST_CASE("write_file_atomically survives two threads writing one target", "[utils]") {
|
||||
ScopedTemporaryDir dir;
|
||||
const boost::filesystem::path target = dir.path() / "shared.json";
|
||||
const std::string a(20000, 'a'), b(20000, 'b');
|
||||
|
||||
std::thread other([&] {
|
||||
for (int i = 0; i < 50; ++i)
|
||||
write_file_atomically(target.string(), a);
|
||||
});
|
||||
for (int i = 0; i < 50; ++i)
|
||||
write_file_atomically(target.string(), b);
|
||||
other.join();
|
||||
|
||||
std::string content;
|
||||
load_string_file(target, content);
|
||||
const bool whole = content == a || content == b;
|
||||
REQUIRE(whole);
|
||||
// No temporary may be left; a scanner on Windows may briefly hold the old
|
||||
// file under another name, so only the temporaries are counted.
|
||||
size_t temporaries = 0;
|
||||
for (auto &entry : boost::filesystem::directory_iterator(dir.path()))
|
||||
if (entry.path().extension() == ".tmp")
|
||||
++temporaries;
|
||||
REQUIRE(temporaries == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("remove_stale_temp_files removes an old tree set aside for removal", "[utils]") {
|
||||
ScopedTemporaryDir dir;
|
||||
const boost::filesystem::path doomed = dir.path() / "removing.4242.0";
|
||||
boost::filesystem::create_directories(doomed / "sub");
|
||||
REQUIRE_FALSE(write_file_atomically((doomed / "sub" / "x.json").string(), "x"));
|
||||
boost::filesystem::last_write_time(doomed, std::time(nullptr) - 7200);
|
||||
boost::filesystem::create_directories(dir.path() / "removing.4242.1"); // just set aside: in progress
|
||||
boost::filesystem::create_directories(dir.path() / "import.4242.0"); // an import in progress
|
||||
|
||||
REQUIRE(remove_stale_temp_files(dir.path()) == 1);
|
||||
REQUIRE_FALSE(boost::filesystem::exists(doomed));
|
||||
REQUIRE(boost::filesystem::exists(dir.path() / "removing.4242.1"));
|
||||
REQUIRE(boost::filesystem::exists(dir.path() / "import.4242.0"));
|
||||
}
|
||||
|
||||
TEST_CASE("remove_if_stale_leftover judges one directory entry, as a scan does per file", "[utils]") {
|
||||
ScopedTemporaryDir dir;
|
||||
REQUIRE_FALSE(write_file_atomically((dir.path() / "a.json").string(), "{}"));
|
||||
REQUIRE_FALSE(write_file_atomically((dir.path() / "a.json.123.7.tmp").string(), "{"));
|
||||
REQUIRE_FALSE(write_file_atomically((dir.path() / "b.json.123.8.tmp").string(), "{"));
|
||||
boost::filesystem::last_write_time(dir.path() / "a.json.123.7.tmp", std::time(nullptr) - 7200);
|
||||
|
||||
REQUIRE_FALSE(remove_if_stale_leftover(boost::filesystem::directory_entry(dir.path() / "a.json")));
|
||||
REQUIRE(remove_if_stale_leftover(boost::filesystem::directory_entry(dir.path() / "a.json.123.7.tmp")));
|
||||
REQUIRE_FALSE(remove_if_stale_leftover(boost::filesystem::directory_entry(dir.path() / "b.json.123.8.tmp"))); // too young
|
||||
REQUIRE(boost::filesystem::exists(dir.path() / "a.json"));
|
||||
REQUIRE_FALSE(boost::filesystem::exists(dir.path() / "a.json.123.7.tmp"));
|
||||
REQUIRE(boost::filesystem::exists(dir.path() / "b.json.123.8.tmp"));
|
||||
}
|
||||
|
||||
TEST_CASE("remove_stale_temp_files removes only old <name>.<pid>.<n>.tmp files", "[utils]") {
|
||||
ScopedTemporaryDir dir;
|
||||
for (const char *name : { "a.json", "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", "a.json.123.5.old", "h.json.old" }) {
|
||||
REQUIRE_FALSE(write_file_atomically((dir.path() / name).string(), "x"));
|
||||
// Two hours old: well past the hour below which a temporary may still be in flight.
|
||||
boost::filesystem::last_write_time(dir.path() / name, std::time(nullptr) - 7200);
|
||||
}
|
||||
// Just written: possibly another instance's in-flight save, so it stays.
|
||||
REQUIRE_FALSE(write_file_atomically((dir.path() / "g.json.7.2.tmp").string(), "x"));
|
||||
|
||||
SECTION("with a name prefix only matching names go; a numbered backup, a one-segment name or another suffix is not removed") {
|
||||
REQUIRE(remove_stale_temp_files(dir.path(), "a.json") == 1);
|
||||
REQUIRE_FALSE(boost::filesystem::exists(dir.path() / "a.json.123.7.tmp"));
|
||||
REQUIRE(boost::filesystem::exists(dir.path() / "a.json.123.5.old"));
|
||||
REQUIRE(boost::filesystem::exists(dir.path() / "a.json.99"));
|
||||
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") {
|
||||
REQUIRE(remove_stale_temp_files(dir.path()) == 2);
|
||||
size_t entries = 0;
|
||||
for (auto &entry : boost::filesystem::directory_iterator(dir.path())) {
|
||||
(void) entry;
|
||||
++entries;
|
||||
}
|
||||
REQUIRE(entries == 10);
|
||||
REQUIRE(boost::filesystem::exists(dir.path() / "a.json.123.5.old"));
|
||||
REQUIRE(boost::filesystem::exists(dir.path() / "a.json"));
|
||||
REQUIRE(boost::filesystem::exists(dir.path() / "h.json.old"));
|
||||
REQUIRE(boost::filesystem::exists(dir.path() / "a.json.99"));
|
||||
REQUIRE(boost::filesystem::exists(dir.path() / "g.json.7.2.tmp"));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("copy_file reports the OS error when the destination cannot be written", "[utils]") {
|
||||
ScopedTemporaryFile source(".txt");
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user