mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-28 19:31:22 +00:00
Keep the Config Dirty After a Failed Write and Sweep a Write's Own Leftovers
Before this change a config whose temporary could not be written stayed dirty, so the idle handler tried again; the last round cleared the flag regardless, which lost a pending change to a transient failure. The writer reports whether the config itself was written and the flag clears only then, as it always did. The Windows rename retry duplicated what WindowsSupport::rename already does, retrying and moving an open destination aside, and multiplied its error logging; it is gone. The file a refused rename moves aside is named so the sweep can clear it after a crash in between, and every successful atomic write sweeps leftovers of earlier writes to the same target, so the caches and the smaller state files are covered too; the name shapes are strict enough that nothing of the user's matches. The lock wait defaults to a second, long against a critical section of milliseconds and short against the GUI thread; a lock file that cannot be opened says to check its owner and permissions; the test timing bounds tolerate a loaded runner; and the helper's header includes what its declarations use.
This commit is contained in:
@@ -1121,10 +1121,8 @@ void AppConfig::save()
|
|||||||
j["local_machines"][local_machine.first] = m_json;
|
j["local_machines"][local_machine.first] = m_json;
|
||||||
}
|
}
|
||||||
const std::string config_str = j.dump(1, '\t');
|
const std::string config_str = j.dump(1, '\t');
|
||||||
write_config_file(path, config_str + "\n", config_str);
|
if (write_config_file(path, config_str + "\n", config_str))
|
||||||
// Cleared even after a failed write, as before: the failure is logged, and
|
m_dirty = false;
|
||||||
// the next change tries again rather than every idle tick.
|
|
||||||
m_dirty = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#else
|
#else
|
||||||
@@ -1311,12 +1309,12 @@ void AppConfig::save()
|
|||||||
config_ss << std::endl;
|
config_ss << std::endl;
|
||||||
|
|
||||||
const std::string config_str = config_ss.str();
|
const std::string config_str = config_ss.str();
|
||||||
write_config_file(path, config_str, config_str);
|
if (write_config_file(path, config_str, config_str))
|
||||||
m_dirty = false;
|
m_dirty = false;
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
void AppConfig::write_config_file(const std::string &path, std::string body, const std::string &checksum_source)
|
bool AppConfig::write_config_file(const std::string &path, std::string body, const std::string &checksum_source)
|
||||||
{
|
{
|
||||||
// Everything before this is assembly; only the writes need the other instances kept out.
|
// Everything before this is assembly; only the writes need the other instances kept out.
|
||||||
InstanceLock instance_lock(lock_path());
|
InstanceLock instance_lock(lock_path());
|
||||||
@@ -1330,8 +1328,11 @@ void AppConfig::write_config_file(const std::string &path, std::string body, con
|
|||||||
BOOST_LOG_TRIVIAL(error) << "Failed to write the backup configuration " << backup_path << ": " << ec.message();
|
BOOST_LOG_TRIVIAL(error) << "Failed to write the backup configuration " << backup_path << ": " << ec.message();
|
||||||
#endif
|
#endif
|
||||||
// The config was always replaced, never written in place, so a read-only one is replaced still.
|
// The config was always replaced, never written in place, so a read-only one is replaced still.
|
||||||
if (const std::error_code ec = write_file_atomically(path, body, false, /*replace_read_only=*/true))
|
if (const std::error_code ec = write_file_atomically(path, body, false, /*replace_read_only=*/true)) {
|
||||||
BOOST_LOG_TRIVIAL(error) << "Failed to write the configuration " << path << ": " << ec.message();
|
BOOST_LOG_TRIVIAL(error) << "Failed to write the configuration " << path << ": " << ec.message();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool AppConfig::get_variant(const std::string &vendor, const std::string &model, const std::string &variant) const
|
bool AppConfig::get_variant(const std::string &vendor, const std::string &model, const std::string &variant) const
|
||||||
|
|||||||
@@ -451,9 +451,10 @@ private:
|
|||||||
|
|
||||||
// Preset for each machine
|
// Preset for each machine
|
||||||
MachineSettingMap m_printer_settings;
|
MachineSettingMap m_printer_settings;
|
||||||
// Writes the assembled config text, and on Windows its checksum and a backup copy. `checksum_source`
|
// Writes the assembled config text, and on Windows its checksum and a backup copy; false when the
|
||||||
|
// config itself could not be written, in which case the caller stays dirty and retries. `checksum_source`
|
||||||
// is the text load() will verify, which for the JSON config ends before the trailing newline.
|
// is the text load() will verify, which for the JSON config ends before the trailing newline.
|
||||||
void write_config_file(const std::string &path, std::string body, const std::string &checksum_source);
|
bool write_config_file(const std::string &path, std::string body, const std::string &checksum_source);
|
||||||
|
|
||||||
bool m_dirty;
|
bool m_dirty;
|
||||||
// Original version found in the ini file before it was overwritten
|
// Original version found in the ini file before it was overwritten
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ bool InstanceLock::open_lock_file(Slot &slot, const std::string &lock_file_path)
|
|||||||
} 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;
|
||||||
BOOST_LOG_TRIVIAL(warning) << "Cannot open lock file " << lock_file_path << ": " << e.what()
|
BOOST_LOG_TRIVIAL(warning) << "Cannot open lock file " << lock_file_path << ": " << e.what()
|
||||||
<< "; other instances are not excluded from writing for the next " << cooldown.count() << " ms";
|
<< " (check its owner and permissions); other instances are not excluded from writing for the next "
|
||||||
|
<< cooldown.count() << " ms";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ namespace Slic3r {
|
|||||||
// exits, so a crashed instance never leaves a stale lock behind.
|
// exits, so a crashed instance never leaves a stale lock behind.
|
||||||
//
|
//
|
||||||
// The lock is best effort: when the lock file cannot be created, or another
|
// The lock is best effort: when the lock file cannot be created, or another
|
||||||
// instance still holds it after `timeout`, the guard keeps only the in-process
|
// instance still holds it after `timeout` (a second by default), the guard keeps only the in-process
|
||||||
// mutex and locked() reports false. Writes then proceed unprotected rather than
|
// mutex and locked() reports false. Writes then proceed unprotected rather than
|
||||||
// letting one hung instance block every other one from saving. After such a
|
// letting one hung instance block every other one from saving. After such a
|
||||||
// timeout the same lock file is not waited on again for `cooldown`, so a batch
|
// timeout the same lock file is not waited on again for `cooldown`, so a batch
|
||||||
@@ -32,7 +32,9 @@ namespace Slic3r {
|
|||||||
class InstanceLock
|
class InstanceLock
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
static constexpr std::chrono::milliseconds default_timeout{2000};
|
// Long against a critical section of milliseconds, short against the GUI
|
||||||
|
// thread, which is where most guards are taken.
|
||||||
|
static constexpr std::chrono::milliseconds default_timeout{1000};
|
||||||
// How long a lock file is left alone after a timed-out wait, a failed open
|
// How long a lock file is left alone after a timed-out wait, a failed open
|
||||||
// or a failing lock call. Mutable so tests can shorten it.
|
// or a failing lock call. Mutable so tests can shorten it.
|
||||||
static inline std::chrono::milliseconds cooldown{10000};
|
static inline std::chrono::milliseconds cooldown{10000};
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
#include <functional>
|
#include <functional>
|
||||||
#include <type_traits>
|
#include <type_traits>
|
||||||
#include <system_error>
|
#include <system_error>
|
||||||
|
#include <initializer_list>
|
||||||
|
#include <string_view>
|
||||||
#include <regex>
|
#include <regex>
|
||||||
|
|
||||||
#include <boost/system/error_code.hpp>
|
#include <boost/system/error_code.hpp>
|
||||||
@@ -233,12 +235,15 @@ extern std::error_code rename_file(const std::string &from, const std::string &t
|
|||||||
// target that is not a regular file (a symlink, device or pipe) is written in
|
// target that is not a regular file (a symlink, device or pipe) is written in
|
||||||
// place, since replacing it would change what it is, and so is a target whose
|
// place, since replacing it would change what it is, and so is a target whose
|
||||||
// replace the filesystem refuses or beside which no temporary can be created.
|
// replace the filesystem refuses or beside which no temporary can be created.
|
||||||
|
// A successful write also removes stale leftovers of earlier writes to the
|
||||||
|
// same target.
|
||||||
extern std::error_code write_file_atomically(const std::string &path, std::initializer_list<std::string_view> chunks, bool binary = false, bool replace_read_only = false);
|
extern std::error_code write_file_atomically(const std::string &path, std::initializer_list<std::string_view> chunks, bool binary = false, bool replace_read_only = false);
|
||||||
inline std::error_code write_file_atomically(const std::string &path, const std::string &content, bool binary = false, bool replace_read_only = false)
|
inline std::error_code write_file_atomically(const std::string &path, const std::string &content, bool binary = false, bool replace_read_only = false)
|
||||||
{ return write_file_atomically(path, { std::string_view(content) }, binary, replace_read_only); }
|
{ return write_file_atomically(path, { std::string_view(content) }, binary, replace_read_only); }
|
||||||
// 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() and
|
||||||
// `dir` at least an hour ago, only names starting with `name_prefix` when it is
|
// the `<name>.<pid>.old` files a crashed rename_file() left in `dir` at least an
|
||||||
// given. Meant for directories the application owns. Returns how many were removed.
|
// hour ago, only names starting with `name_prefix` when it is given. 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),
|
// 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.
|
// for noticing that a path was unlinked and recreated. Empty when it cannot be read.
|
||||||
|
|||||||
+26
-23
@@ -706,16 +706,8 @@ namespace WindowsSupport
|
|||||||
std::error_code rename_file(const std::string &from, const std::string &to)
|
std::error_code rename_file(const std::string &from, const std::string &to)
|
||||||
{
|
{
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
std::error_code ec = WindowsSupport::rename(from, to);
|
// Retries and moves an open destination aside itself.
|
||||||
// Windows refuses to replace a file another process holds open without
|
return WindowsSupport::rename(from, to);
|
||||||
// FILE_SHARE_DELETE, which is how the C runtime opens files for reading; a
|
|
||||||
// reader is done in milliseconds, so wait it out. A target that is not
|
|
||||||
// writable at all is refused for good and not worth waiting on.
|
|
||||||
for (int attempt = 0; attempt < 20 && ec == std::errc::permission_denied && ::_waccess(boost::nowide::widen(to).c_str(), 2) == 0; ++ attempt) {
|
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(25));
|
|
||||||
ec = WindowsSupport::rename(from, to);
|
|
||||||
}
|
|
||||||
return ec;
|
|
||||||
#else
|
#else
|
||||||
// rename(2) replaces an existing target atomically; removing it first would
|
// rename(2) replaces an existing target atomically; removing it first would
|
||||||
// leave a window in which the file does not exist at all.
|
// leave a window in which the file does not exist at all.
|
||||||
@@ -728,6 +720,7 @@ std::error_code rename_file(const std::string &from, const std::string &to)
|
|||||||
// a refusal that was really about the source never costs the target.
|
// a refusal that was really about the source never costs the target.
|
||||||
const bool replace_refused = err == EPERM || err == EACCES || err == EEXIST || err == ENOTEMPTY || err == EBUSY || err == ENOTSUP || err == EOPNOTSUPP;
|
const bool replace_refused = err == EPERM || err == EACCES || err == EEXIST || err == ENOTEMPTY || err == EBUSY || err == ENOTSUP || err == EOPNOTSUPP;
|
||||||
if (replace_refused) {
|
if (replace_refused) {
|
||||||
|
// Named so remove_stale_temp_files() can clear it after a crash in between.
|
||||||
const std::string aside = to + "." + std::to_string(get_current_pid()) + ".old";
|
const std::string aside = to + "." + std::to_string(get_current_pid()) + ".old";
|
||||||
if (boost::nowide::rename(to.c_str(), aside.c_str()) == 0) {
|
if (boost::nowide::rename(to.c_str(), aside.c_str()) == 0) {
|
||||||
if (boost::nowide::rename(from.c_str(), to.c_str()) == 0) {
|
if (boost::nowide::rename(from.c_str(), to.c_str()) == 0) {
|
||||||
@@ -799,13 +792,17 @@ std::error_code write_file_atomically(const std::string &path, std::initializer_
|
|||||||
#endif
|
#endif
|
||||||
if (const std::error_code ec = rename_file(tmp_path, path)) {
|
if (const std::error_code ec = rename_file(tmp_path, path)) {
|
||||||
boost::nowide::remove(tmp_path.c_str());
|
boost::nowide::remove(tmp_path.c_str());
|
||||||
// A reader on Windows that outlasted rename_file()'s wait, or a mount
|
// A reader on Windows holding the target open without FILE_SHARE_DELETE,
|
||||||
// that cannot replace a file at all. Losing the save is worse than a
|
// or a mount that cannot replace a file at all. Losing the save is worse
|
||||||
// reader seeing a partial file, so write in place the way this used to
|
// than a reader seeing a partial file, so write in place the way this
|
||||||
// work before the atomic path existed.
|
// 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);
|
||||||
}
|
}
|
||||||
|
// Crash leftovers of earlier writes to this same file, wherever it lives;
|
||||||
|
// the name shapes are specific enough that nothing of the user's matches.
|
||||||
|
const boost::filesystem::path target_path(path);
|
||||||
|
remove_stale_temp_files(target_path.parent_path(), target_path.filename().string());
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -832,15 +829,11 @@ std::string file_identity(const std::string &path)
|
|||||||
|
|
||||||
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>.<digits>.tmp, exactly the shape write_file_atomically() makes.
|
// <name_prefix>...<pid>.<n>.tmp, exactly the shape write_file_atomically()
|
||||||
auto is_temp_name = [&name_prefix](const std::string &name) {
|
// makes, or <name_prefix>...<pid>.old, the shape rename_file() moves a
|
||||||
if (name.compare(0, name_prefix.size(), name_prefix) != 0)
|
// target aside under.
|
||||||
return false;
|
auto digit_segments_before = [](const std::string &name, size_t end, int count) {
|
||||||
static const std::string suffix = ".tmp";
|
for (int segment = 0; segment < count; ++ segment) {
|
||||||
if (name.size() <= suffix.size() || name.compare(name.size() - suffix.size(), suffix.size(), suffix) != 0)
|
|
||||||
return false;
|
|
||||||
size_t end = name.size() - suffix.size();
|
|
||||||
for (int segment = 0; segment < 2; ++ segment) {
|
|
||||||
const size_t dot = name.rfind('.', end - 1);
|
const size_t dot = name.rfind('.', end - 1);
|
||||||
if (dot == std::string::npos || dot == 0 || dot + 1 == end ||
|
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'; }))
|
! std::all_of(name.begin() + dot + 1, name.begin() + end, [](char c) { return c >= '0' && c <= '9'; }))
|
||||||
@@ -849,6 +842,16 @@ size_t remove_stale_temp_files(const boost::filesystem::path &dir, const std::st
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
auto is_temp_name = [&](const std::string &name) {
|
||||||
|
if (name.compare(0, name_prefix.size(), name_prefix) != 0)
|
||||||
|
return false;
|
||||||
|
static const std::string tmp_suffix = ".tmp", old_suffix = ".old";
|
||||||
|
if (name.size() > tmp_suffix.size() && name.compare(name.size() - tmp_suffix.size(), tmp_suffix.size(), tmp_suffix) == 0)
|
||||||
|
return digit_segments_before(name, name.size() - tmp_suffix.size(), 2);
|
||||||
|
if (name.size() > old_suffix.size() && name.compare(name.size() - old_suffix.size(), old_suffix.size(), old_suffix) == 0)
|
||||||
|
return digit_segments_before(name, name.size() - old_suffix.size(), 1);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
// 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, and hosts sharing a
|
// so a temporary this young may still be in flight, and hosts sharing a
|
||||||
// data dir may disagree on the time by minutes; a crash leftover is old.
|
// data dir may disagree on the time by minutes; a crash leftover is old.
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ TEST_CASE("InstanceLock creates its lock file and holds it for the guard's scope
|
|||||||
const auto started = std::chrono::steady_clock::now();
|
const auto started = std::chrono::steady_clock::now();
|
||||||
InstanceLock again(path, 5000ms);
|
InstanceLock again(path, 5000ms);
|
||||||
REQUIRE(again.locked());
|
REQUIRE(again.locked());
|
||||||
REQUIRE(std::chrono::steady_clock::now() - started < 1000ms);
|
// 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]")
|
TEST_CASE("InstanceLock nests within one thread", "[InstanceLock]")
|
||||||
@@ -193,7 +194,7 @@ TEST_CASE("InstanceLock yields to another process and reports it", "[InstanceLoc
|
|||||||
|
|
||||||
REQUIRE_FALSE(locked_while_child_holds);
|
REQUIRE_FALSE(locked_while_child_holds);
|
||||||
REQUIRE_FALSE(locked_during_cooldown);
|
REQUIRE_FALSE(locked_during_cooldown);
|
||||||
REQUIRE(cooldown_wait < 1000ms);
|
REQUIRE(cooldown_wait < 4000ms);
|
||||||
// A guard inside the cool-down still takes the lock when it is free.
|
// A guard inside the cool-down still takes the lock when it is free.
|
||||||
InstanceLock lock(path);
|
InstanceLock lock(path);
|
||||||
REQUIRE(lock.locked());
|
REQUIRE(lock.locked());
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ TEST_CASE("write_file_atomically survives two threads writing one target", "[uti
|
|||||||
|
|
||||||
TEST_CASE("remove_stale_temp_files removes only old <name>.<pid>.<n>.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.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" }) {
|
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", "a.json.123.old", "h.json.old" }) {
|
||||||
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);
|
||||||
@@ -198,20 +198,22 @@ TEST_CASE("remove_stale_temp_files removes only old <name>.<pid>.<n>.tmp files",
|
|||||||
REQUIRE_FALSE(write_file_atomically((dir.path() / "g.json.7.2.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; a numbered backup or a one-segment name 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") == 1);
|
REQUIRE(remove_stale_temp_files(dir.path(), "a.json") == 2);
|
||||||
REQUIRE_FALSE(boost::filesystem::exists(dir.path() / "a.json.123.7.tmp"));
|
REQUIRE_FALSE(boost::filesystem::exists(dir.path() / "a.json.123.7.tmp"));
|
||||||
|
REQUIRE_FALSE(boost::filesystem::exists(dir.path() / "a.json.123.old"));
|
||||||
REQUIRE(boost::filesystem::exists(dir.path() / "a.json.99"));
|
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() / "a.json.12.tmp"));
|
||||||
REQUIRE(boost::filesystem::exists(dir.path() / "b.info.4.0.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()) == 2);
|
REQUIRE(remove_stale_temp_files(dir.path()) == 3);
|
||||||
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 == 7);
|
REQUIRE(entries == 8);
|
||||||
|
REQUIRE(boost::filesystem::exists(dir.path() / "h.json.old"));
|
||||||
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.2.tmp"));
|
REQUIRE(boost::filesystem::exists(dir.path() / "g.json.7.2.tmp"));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user