Atomic IFC file writes to prevent corruption on interrupted save (#4797)

file.write() streamed directly onto the target path, so a crash mid-write
left a truncated file with dangling STEP references. Serialize to a temp
file in the same directory, then atomically rename it onto the target.

- New IfcUtil::path::atomic_rename_file: std::rename on POSIX, MoveFileExW
  with MOVEFILE_REPLACE_EXISTING on Windows. Unlike rename_file it never
  unlinks the destination first, so there is no window where it goes missing.
- Fully in C++/swig (per aothms), so the FILE_NAME header is untouched: it
  comes from the model header, not the output path (verified empirically).
- Temp lives next to the target so the rename stays on one filesystem.
- Stream is closed before the rename (Windows cannot move an open file).
- On any write error the temp is removed and the original target is intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Petru Conduraru
2026-07-07 12:49:08 +03:00
committed by Thomas Krijnen
parent b2d58d0b81
commit 4776bd7639
3 changed files with 69 additions and 5 deletions
+45 -5
View File
@@ -117,10 +117,51 @@ PyObject* get_feature(const std::string& x) {
%{
#include <fstream>
#include <random>
static const std::string& helper_fn_declaration_get_name(const IfcParse::declaration* decl) {
return decl->name();
}
// Atomic IFC/STEP write (issue #4797): serialize to a temporary file next to
// the destination, then atomically rename it onto the destination. If the
// process is interrupted mid-write, the destination is never truncated or
// left with dangling STEP references; at most a stray temp file remains, which
// the caller can safely ignore. Keeping the temp in the same directory means
// the rename stays on a single filesystem and is therefore atomic. The temp
// path never leaks into the FILE_NAME header, which is derived from the model
// header, not the output path.
template <typename T>
static void helper_fn_atomic_write(T& file_obj, const std::string& fn) {
std::random_device rd;
const std::string temp_fn = fn + "." + std::to_string(rd()) + ".tmp";
{
// Same open mode as a plain write so the bytes are identical.
std::ofstream f(IfcUtil::path::from_utf8(temp_fn).c_str());
if (!f.good()) {
// The temp file could not be created (e.g. directory not
// writable). Nothing was touched; report as a normal write error.
throw std::runtime_error("Failed to write to path: '" + fn + "', check folder and file permissions.");
}
f << file_obj;
f.flush();
if (!f.good()) {
// Serialization failed (e.g. disk full). Clean up the partial temp
// and abort. The existing destination is left intact.
f.close();
IfcUtil::path::delete_file(temp_fn);
throw std::runtime_error("Failed to write to path: '" + fn + "', the file may be incomplete.");
}
// The ofstream destructor at the end of this scope closes the stream.
// On Windows the file must be closed before it can be renamed.
}
if (!IfcUtil::path::atomic_rename_file(temp_fn, fn)) {
IfcUtil::path::delete_file(temp_fn);
throw std::runtime_error("Failed to write to path: '" + fn + "', could not replace the existing file.");
}
}
static IfcUtil::ArgumentType helper_fn_attribute_type(const IfcUtil::IfcBaseClass* inst, unsigned i) {
const IfcParse::parameter_type* pt = 0;
if (inst->declaration().as_entity()) {
@@ -219,11 +260,10 @@ private:
}
void write(const std::string& fn) {
std::ofstream f(IfcUtil::path::from_utf8(fn).c_str());
if (!f.good()) {
throw std::runtime_error("Failed to write to path: '" + fn + "', check folder and file permissions.");
}
f << (*$self);
// Atomic write: serialize to a temp file next to the target, then
// atomically rename it into place, so an interrupted write can never
// corrupt the destination (issue #4797).
helper_fn_atomic_write(*$self, fn);
}
std::string to_string() {