Files
OrcaSlicer/tests/libslic3r/test_utils.cpp
T
Hanif Koh 5181a7fe26 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, which the OS releases when its holder dies. It is best
effort: when the lock file cannot be created, or another instance still
holds it after two seconds, the guard logs a warning and lets the write
proceed rather than letting a hung instance block every other one.

AppConfig::save() and load() hold OrcaSlicer.conf.lock; load is included
because the Windows path restores from the .bak copy, and because Windows
cannot replace a file another process has open, so an unlocked reader
there made the other instance's save fail. Every user preset writer and
reader holds user.lock: Preset::save(), save_info(), load_info() and
remove_files(), the whole directory scan in load_presets(), the bundle
metadata file, the .info removal after a cloud-confirmed delete and the
bundle folder removal on unsubscribe. The guard sits at the leaf writers
on purpose: set_sync_info_and_save() calls save_info() under the preset
collection mutex, so a batch lock around save_user_presets(), which takes
that mutex through delete_preset(), would invert the order.

Write preset JSON, .info and bundle metadata files through a temporary
file beside the target that is renamed over it, so a reader that never
waits still sees a complete old or new file. On Linux and macOS
rename_file() deleted the target before renaming, leaving a window in
which the file did not exist at all, and returned a meaningless error
code on failure; rename() replaces atomically, so call it directly.
AppConfig::save() now logs a failed final rename instead of dropping the
save silently.
2026-09-24 02:36:05 +08:00

181 lines
7.2 KiB
C++

#include <catch2/catch_all.hpp>
#include "libslic3r/Utils.hpp"
#include "test_utils.hpp"
#include <boost/filesystem.hpp>
#include <algorithm>
#include <cctype>
#include <fstream>
#include <string>
#ifndef _WIN32
#include <unistd.h> // getuid
#endif
using namespace Slic3r;
TEST_CASE("per_user_temp_dir composes a per-user temp root", "[utils]") {
const std::string base = "/tmp";
SECTION("an empty id returns base unchanged") {
REQUIRE(per_user_temp_dir(base, "") == base);
}
SECTION("a non-empty id is appended at the top level") {
REQUIRE(per_user_temp_dir(base, "1000") == base + "/orcaslicer_1000");
}
SECTION("distinct ids produce distinct roots") {
REQUIRE(per_user_temp_dir(base, "1000") != per_user_temp_dir(base, "1001"));
}
}
TEST_CASE("per_user_temp_id follows the platform contract", "[utils]") {
const std::string id = per_user_temp_id();
SECTION("stable across calls") {
REQUIRE(per_user_temp_id() == id);
}
#ifdef _WIN32
SECTION("empty on Windows (its temp dir is already per-user)") {
REQUIRE(id.empty());
}
#else
SECTION("the current uid on Linux/macOS") {
REQUIRE_FALSE(id.empty());
REQUIRE(id == std::to_string(static_cast<unsigned long>(::getuid())));
}
#endif
}
// The end-to-end contract callers depend on: the temp root is left alone on
// Windows and isolated per user on Linux/macOS.
TEST_CASE("per-user temp root is unchanged on Windows, isolated elsewhere", "[utils]") {
const std::string base = "/tmp";
const std::string root = per_user_temp_dir(base, per_user_temp_id());
#ifdef _WIN32
REQUIRE(root == base);
#else
REQUIRE(root != base);
REQUIRE_THAT(root, Catch::Matchers::StartsWith(base + "/orcaslicer_"));
#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";
REQUIRE(write_file_atomically(target.string(), "x"));
REQUIRE_FALSE(boost::filesystem::exists(target));
}
TEST_CASE("copy_file reports the OS error when the destination cannot be written", "[utils]") {
ScopedTemporaryFile source(".txt");
{
std::ofstream ofs(source.string(), std::ios::binary);
ofs << "orca";
}
REQUIRE(boost::filesystem::exists(source.path()));
// A directory that was never created, so the copy fails on every platform.
const boost::filesystem::path destination = source.path().parent_path() / "orca-missing-dir" / "copy.txt";
REQUIRE_FALSE(boost::filesystem::exists(destination.parent_path()));
std::string error_message;
REQUIRE(copy_file(source.string(), destination.string(), error_message) == FAIL_COPY_FILE);
REQUIRE_FALSE(error_message.empty());
#ifdef _WIN32
// The Windows branch formats GetLastError() itself. Writing that as
// "Error: " + errCode adds an integer to a string literal, which indexes into the
// literal instead of appending and runs off its end for any code above 7.
const std::string prefix = "Error: ";
REQUIRE(error_message.rfind(prefix, 0) == 0);
const std::string code = error_message.substr(prefix.size());
REQUIRE_FALSE(code.empty());
REQUIRE(std::all_of(code.begin(), code.end(), [](unsigned char c) { return std::isdigit(c) != 0; }));
#endif // _WIN32
}
TEST_CASE("A resolved input path still names the same file after the working directory changes", "[utils]") {
ScopedTemporaryFile model(".3mf");
{ std::ofstream out(model.string()); out << "3mf"; }
const std::string name = model.path().filename().string();
// Resolve the bare name from the directory holding the file, then move away from it. The guard
// restores the directory the test started in, wherever this leaves it.
ScopedWorkingDirectory cwd(model.path().parent_path());
const std::string resolved = resolve_cli_input_path(name);
boost::filesystem::current_path(boost::filesystem::path(TEST_DATA_DIR));
REQUIRE(boost::filesystem::exists(resolved));
REQUIRE(boost::filesystem::equivalent(resolved, model.path()));
// Control: the bare name finds nothing from here, so resolving it this late would have failed.
REQUIRE_FALSE(boost::filesystem::exists(name));
}
TEST_CASE("resolve_cli_input_path completes a relative path against the working directory", "[utils]") {
ScopedWorkingDirectory cwd(boost::filesystem::temp_directory_path());
// Read back rather than reusing temp_directory_path(): changing to it resolves any symlink.
const boost::filesystem::path here = boost::filesystem::current_path();
SECTION("a bare name") {
REQUIRE(resolve_cli_input_path("model.3mf") == (here / "model.3mf").make_preferred().string());
}
SECTION("a ./ prefix is dropped") {
REQUIRE(resolve_cli_input_path("./model.3mf") == (here / "model.3mf").make_preferred().string());
}
SECTION("a ../ traversal is collapsed") {
REQUIRE(resolve_cli_input_path("../model.3mf") == (here.parent_path() / "model.3mf").make_preferred().string());
}
}
TEST_CASE("resolve_cli_input_path leaves inputs that must not be completed unchanged", "[utils]") {
SECTION("an absolute path") {
const boost::filesystem::path absolute = (boost::filesystem::temp_directory_path() / "model.3mf").make_preferred();
REQUIRE(resolve_cli_input_path(absolute.string()) == absolute.string());
}
#ifdef _WIN32
// Every absolute form Windows accepts opens today, so each must come back byte for byte:
// normalizing them would rewrite the forward slashes and rebuild the \\?\ and UNC prefixes.
SECTION("an absolute Windows path of any form") {
for (const std::string absolute : {R"(C:\models\model.3mf)",
R"(C:/models/model.3mf)",
R"(\\server\share\model.3mf)",
R"(\\?\C:\models\model.3mf)"})
REQUIRE(resolve_cli_input_path(absolute) == absolute);
}
#endif
// These are downloaded rather than opened, and completing one would produce a path, not a URL.
SECTION("a custom open protocol URL") {
for (const std::string url : {"orcaslicer://open/?file=https://example.com/model.3mf",
"prusaslicer://open/?file=https://example.com/model.3mf",
"bambustudio://open/?file=https://example.com/model.3mf",
"cura://open/?file=https://example.com/model.3mf"})
REQUIRE(resolve_cli_input_path(url) == url);
}
SECTION("an empty argument") {
REQUIRE(resolve_cli_input_path("").empty());
}
}