Unravel Engine C++ Reference
Loading...
Searching...
No Matches
asset_writer.cpp
Go to the documentation of this file.
1#include "asset_writer.h"
2
3#ifdef _WIN32
4#include <windows.h>
5#else
6#include <fcntl.h> // open
7#include <unistd.h> // fsync, close
8#endif
9
10#include <chrono>
11#include <mutex>
12#include <random>
13#include <string>
14#include <thread>
15#include <vector>
16
17#include <logging/logging.h>
18
19namespace unravel
20{
21namespace asset_writer
22{
23
24namespace
25{
26constexpr const char charset[] = "0123456789"
27 "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
28 "abcdefghijklmnopqrstuvwxyz";
29
30constexpr size_t max_index = (sizeof(charset) - 1);
31
32using random_generator_t = ::std::mt19937;
33
34static const auto make_seeded_engine = []()
35{
36 std::random_device r;
37 std::hash<std::thread::id> hasher;
38 std::seed_seq seed(std::initializer_list<typename random_generator_t::result_type>{
39 static_cast<typename random_generator_t::result_type>(
40 std::chrono::system_clock::now().time_since_epoch().count()),
41 static_cast<typename random_generator_t::result_type>(hasher(std::this_thread::get_id())),
42 r(),
43 r(),
44 r(),
45 r(),
46 r(),
47 r(),
48 r(),
49 r()});
50 return random_generator_t(seed);
51};
52
53std::string generate_random_string(size_t len)
54{
55 static thread_local random_generator_t engine(make_seeded_engine());
56
57 std::uniform_int_distribution<> dist(0, max_index);
58
59 std::string str;
60 str.reserve(len);
61
62 for (size_t i = 0; i < len; i++)
63 {
64 str.push_back(charset[dist(engine)]);
65 }
66
67 return str;
68}
69
70//------------------------------------------------------------------------------
71// Recognise our temp-file pattern: a hidden file named `.<UUID>.temp` (the
72// leading dot is what hides it in most file browsers).
73//------------------------------------------------------------------------------
74auto looks_like_temp_file(const fs::path& p) noexcept -> bool
75{
76 const auto name = p.filename().string();
77 if(name.size() < 2 || name.front() != '.')
78 {
79 return false;
80 }
81 return p.extension() == ".temp";
82}
83
84// Forward declaration; used by the cleanup function below.
85auto remove_temp_with_retry(const fs::path& temp,
86 int max_retries,
87 int base_delay_ms,
88 std::string* last_error_out) noexcept -> bool;
89
90//------------------------------------------------------------------------------
91// Single-entry helper used by `cleanup_stale_temp_files`. Lives in the
92// anonymous namespace (rather than being a lambda) so that __FUNCTION__ inside
93// the APPLOG_* macros expands to a useful name, and to keep the outer function
94// below the cognitive-complexity threshold.
95//------------------------------------------------------------------------------
96void try_remove_stale_temp(const fs::path& p,
97 fs::file_time_type now,
98 std::chrono::seconds min_age,
99 std::size_t& removed_counter) noexcept
100{
101 if(!looks_like_temp_file(p))
102 {
103 return;
104 }
105
106 fs::error_code stat_ec;
107 const auto write_time = fs::last_write_time(p, stat_ec);
108 if(stat_ec)
109 {
110 return;
111 }
112 if(std::chrono::duration_cast<std::chrono::seconds>(now - write_time) < min_age)
113 {
114 // Probably a concurrent in-flight write — leave it alone.
115 return;
116 }
117
118 std::string diagnostic;
119 if(remove_temp_with_retry(p, 5, 10, &diagnostic))
120 {
121 ++removed_counter;
122 APPLOG_INFO("asset_writer: Removed stale temp file: {}", p.generic_string());
123 }
124 else
125 {
126 APPLOG_WARNING("asset_writer: Could not remove stale temp file: {} ({})",
127 p.generic_string(),
128 diagnostic);
129 }
130}
131
132} // namespace
133#define ATOMIC_SAVE
134auto sync_file(const fs::path& temp, fs::error_code& ec) noexcept -> bool
135{
136#ifdef _WIN32
137 // flush via FlushFileBuffers
138 {
139 HANDLE h = CreateFileW(temp.wstring().c_str(),
140 GENERIC_WRITE,
141 FILE_SHARE_READ | FILE_SHARE_WRITE,
142 nullptr,
143 OPEN_EXISTING,
144 FILE_ATTRIBUTE_NORMAL,
145 nullptr);
146 if(h == INVALID_HANDLE_VALUE)
147 {
148 ec = fs::error_code(static_cast<int>(GetLastError()), std::system_category());
149 return false;
150 }
151 if(!FlushFileBuffers(h))
152 {
153 ec = fs::error_code(static_cast<int>(GetLastError()), std::system_category());
154 CloseHandle(h);
155 return false;
156 }
157 CloseHandle(h);
158 }
159#else
160 int fd = ::open(temp.c_str(), O_RDWR);
161 if(fd < 0)
162 {
163 ec = fs::error_code(errno, std::generic_category());
164 return false;
165 }
166 if(::fsync(fd) < 0)
167 {
168 ec = fs::error_code(errno, std::generic_category());
169 ::close(fd);
170 return false;
171 }
172 ::close(fd);
173#endif
174 return true;
175}
176
177//------------------------------------------------------------------------------
178// Atomically rename src -> dst, overwriting dst if it exists.
179//------------------------------------------------------------------------------
180auto atomic_rename_file(const fs::path& src, const fs::path& dst, fs::error_code& ec) noexcept -> bool
181{
182 ec.clear();
183 fs::rename(src, dst, ec);
184 return !ec;
185}
186
187namespace
188{
189
190//------------------------------------------------------------------------------
191// Retry fs::rename a few times with exponential backoff. On Windows, rename can
192// fail transiently when AV scanners, indexers, or other processes hold the
193// destination open with restrictive sharing modes. Most of those are released
194// within a few hundred milliseconds.
195//------------------------------------------------------------------------------
196auto atomic_rename_with_retry(const fs::path& src,
197 const fs::path& dst,
198 fs::error_code& ec,
199 int max_retries = 5,
200 int base_delay_ms = 10) noexcept -> bool
201{
202 for(int i = 0; i < max_retries; ++i)
203 {
204 ec.clear();
205 fs::rename(src, dst, ec);
206 if(!ec)
207 {
208 return true;
209 }
210 std::this_thread::sleep_for(std::chrono::milliseconds(base_delay_ms * (1 << i)));
211 }
212 return false;
213}
214
215#ifdef _WIN32
216//------------------------------------------------------------------------------
217// Windows-specific POSIX-style delete. Opens the file with shared delete access
218// and asks the OS to unlink the name as soon as we close our handle, regardless
219// of any other open handles. This succeeds in every case where:
220// - the file exists, AND
221// - at least one other holder of the file opened it with FILE_SHARE_DELETE
222// which covers most file watchers, asset preview thumbnailers, indexers, and
223// Defender's on-access scanner.
224//
225// Prefers `FileDispositionInfoEx` with POSIX semantics (Windows 10 RS1+) — this
226// unlinks the *name* immediately so the path becomes reusable even if the OS
227// keeps the file around until the last handle is closed. Falls back to
228// `FileDispositionInfo` on older systems.
229//------------------------------------------------------------------------------
230auto windows_force_delete(const fs::path& path, DWORD& last_error) noexcept -> bool
231{
232 last_error = 0;
233
234 HANDLE h = CreateFileW(path.wstring().c_str(),
235 DELETE | SYNCHRONIZE,
236 FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
237 nullptr,
238 OPEN_EXISTING,
239 FILE_ATTRIBUTE_NORMAL,
240 nullptr);
241 if(h == INVALID_HANDLE_VALUE)
242 {
243 last_error = GetLastError();
244 return false;
245 }
246
247 bool ok = false;
248
249#if defined(FILE_DISPOSITION_FLAG_POSIX_SEMANTICS)
250 FILE_DISPOSITION_INFO_EX disp_ex{};
251 disp_ex.Flags = FILE_DISPOSITION_FLAG_DELETE | FILE_DISPOSITION_FLAG_POSIX_SEMANTICS;
252 ok = (SetFileInformationByHandle(h, FileDispositionInfoEx, &disp_ex, sizeof(disp_ex)) != FALSE);
253 if(!ok)
254 {
255 last_error = GetLastError();
256 }
257#endif
258
259 if(!ok)
260 {
261 FILE_DISPOSITION_INFO disp{};
262 disp.DeleteFile = TRUE;
263 ok = (SetFileInformationByHandle(h, FileDispositionInfo, &disp, sizeof(disp)) != FALSE);
264 if(!ok)
265 {
266 last_error = GetLastError();
267 }
268 }
269
270 CloseHandle(h);
271 return ok;
272}
273#endif
274
275//------------------------------------------------------------------------------
276// Try to remove a temporary file with retries for transient locks (e.g.
277// antivirus scanning on Windows). Returns true if the file was removed.
278//
279// `last_error_out` (optional) receives the most recent platform error so the
280// caller can include it in diagnostics when all attempts fail.
281//
282// Defaults give ~1.6s of total wait time spread across 8 attempts; each sleep
283// is capped at 500 ms so we never block the caller on a runaway exponential.
284// On Windows, falls back to a POSIX-style delete that succeeds even when other
285// processes still hold the file open (as long as they used FILE_SHARE_DELETE).
286//------------------------------------------------------------------------------
287auto remove_temp_with_retry(const fs::path& temp,
288 int max_retries = 8,
289 int base_delay_ms = 10,
290 std::string* last_error_out = nullptr) noexcept -> bool
291{
292 fs::error_code remove_ec;
293 for(int i = 0; i < max_retries; ++i)
294 {
295 fs::remove(temp, remove_ec);
296 if(!remove_ec || !fs::exists(temp, remove_ec))
297 {
298 return true;
299 }
300
301 // Exponential backoff with a 500 ms cap per sleep.
302 const int shift = std::min(i, 16); // guard against UB on 1<<i for very large i
303 const int sleep_ms = std::min(base_delay_ms * (1 << shift), 500);
304 std::this_thread::sleep_for(std::chrono::milliseconds(sleep_ms));
305 }
306
307 // Last-resort Windows fallback: unlink via a shared-delete handle so we don't
308 // need the file to be lock-free at this exact moment. Other platforms have
309 // already been served by `fs::remove` above (which uses unlink and doesn't
310 // suffer from Windows-style sharing-violation failures).
311#ifdef _WIN32
312 DWORD win_err = 0;
313 if(windows_force_delete(temp, win_err))
314 {
315 return true;
316 }
317 if(last_error_out != nullptr)
318 {
319 *last_error_out = "fs::remove=" + remove_ec.message() +
320 " (code " + std::to_string(remove_ec.value()) + "); " +
321 "windows_force_delete=" + std::to_string(win_err);
322 }
323 return false;
324#else
325 if(last_error_out != nullptr)
326 {
327 *last_error_out =
328 "fs::remove=" + remove_ec.message() + " (code " + std::to_string(remove_ec.value()) + ")";
329 }
330 return false;
331#endif
332}
333
334//------------------------------------------------------------------------------
335// Process-global deferred-cleanup queue.
336//
337// When a temp file can't be removed immediately (typically because an external
338// process — file watcher, AV scanner, indexer, asset preview — is briefly
339// holding it open), we add it to this queue. The queue is drained at the start
340// of every subsequent atomic write/copy, by which point the external holder has
341// almost always released the file. This avoids leaking temp files to the next
342// process startup while keeping individual write paths fast.
343//------------------------------------------------------------------------------
344auto deferred_cleanup_mutex() -> std::mutex&
345{
346 static std::mutex m;
347 return m;
348}
349
350auto deferred_cleanup_paths() -> std::vector<fs::path>&
351{
352 static std::vector<fs::path> v;
353 return v;
354}
355
356void enqueue_deferred_cleanup(fs::path p) noexcept
357{
358 try
359 {
360 std::lock_guard<std::mutex> lk(deferred_cleanup_mutex());
361 deferred_cleanup_paths().push_back(std::move(p));
362 }
363 catch(...) // NOLINT(bugprone-empty-catch): noexcept context, can't propagate; cleanup_stale_temp_files at startup is the safety net
364 {
365 // Allocation/lock failure: drop the path. It'll be picked up by
366 // cleanup_stale_temp_files on next startup.
367 }
368}
369
370void drain_deferred_cleanup() noexcept
371{
372 std::vector<fs::path> snapshot;
373 try
374 {
375 std::lock_guard<std::mutex> lk(deferred_cleanup_mutex());
376 snapshot.swap(deferred_cleanup_paths());
377 }
378 catch(...)
379 {
380 return;
381 }
382
383 if(snapshot.empty())
384 {
385 return;
386 }
387
388 std::vector<fs::path> still_pending;
389 still_pending.reserve(snapshot.size());
390
391 for(auto& p : snapshot)
392 {
393 // Cheaper retry budget here: we've already given the file ~1.6 s when
394 // the original write tried to delete it. Subsequent attempts just need
395 // to catch the moment the holder lets go.
396 fs::error_code probe_ec;
397 if(!fs::exists(p, probe_ec))
398 {
399 // Already gone (maybe the watcher cleaned it up). Drop silently.
400 continue;
401 }
402
403 if(remove_temp_with_retry(p, 3, 5))
404 {
405 APPLOG_TRACE("asset_writer: Deferred cleanup removed temp file: {}", p.generic_string());
406 }
407 else
408 {
409 still_pending.push_back(std::move(p));
410 }
411 }
412
413 if(!still_pending.empty())
414 {
415 try
416 {
417 std::lock_guard<std::mutex> lk(deferred_cleanup_mutex());
418 auto& dst = deferred_cleanup_paths();
419 dst.insert(dst.end(),
420 std::make_move_iterator(still_pending.begin()),
421 std::make_move_iterator(still_pending.end()));
422 }
423 catch(...) // NOLINT(bugprone-empty-catch): noexcept context; orphans get cleaned at next startup
424 {
425 // If we can't re-enqueue, the files become orphans for next startup.
426 }
427 }
428}
429
430//------------------------------------------------------------------------------
431// RAII guard for a temp file. Removes the file on destruction unless commit()
432// has been called. This makes cleanup automatic on every exit path including
433// exceptions thrown from the callback in atomic_write_file (we are noexcept,
434// so an unwinding exception would terminate — but the destructor still runs).
435//
436// Always logs at WARNING when the file existed but couldn't be removed; that's
437// the case the user was hitting (silent leak when AV holds the file briefly).
438//------------------------------------------------------------------------------
439class temp_file_guard
440{
441public:
442 explicit temp_file_guard(fs::path path) noexcept : path_(std::move(path))
443 {
444 }
445
446 ~temp_file_guard() noexcept
447 {
448 if(committed_ || path_.empty())
449 {
450 return;
451 }
452 fs::error_code probe_ec;
453 if(!fs::exists(path_, probe_ec))
454 {
455 // Never created, or already gone — nothing to do.
456 return;
457 }
458
459 std::string diagnostic;
460 if(remove_temp_with_retry(path_, 8, 10, &diagnostic))
461 {
462 return;
463 }
464
465 // External holder still has the file open. Queue it for retry on the
466 // next atomic write rather than warning the user about a leak that
467 // we'll almost certainly recover from in milliseconds.
469 "asset_writer: Temp file still locked after immediate retries; queued for deferred cleanup: {} ({})",
470 path_.generic_string(),
471 diagnostic);
472 enqueue_deferred_cleanup(path_);
473 }
474
475 temp_file_guard(const temp_file_guard&) = delete;
476 auto operator=(const temp_file_guard&) -> temp_file_guard& = delete;
477 temp_file_guard(temp_file_guard&&) = delete;
478 auto operator=(temp_file_guard&&) -> temp_file_guard& = delete;
479
480 void commit() noexcept
481 {
482 committed_ = true;
483 }
484
485private:
486 fs::path path_;
487 bool committed_{false};
488};
489
490} // namespace
491
492//------------------------------------------------------------------------------
493// Generate a unique temp‑path in `dir`.
494// Bounded retries to avoid a runaway loop if fs::exists is permanently failing.
495// Returns false (with ec set) on error so callers can detect failure.
496//------------------------------------------------------------------------------
497auto make_temp_path(const fs::path& dir, fs::path& out, fs::error_code& ec) noexcept -> bool
498{
499 ec.clear();
500 if(!fs::exists(dir, ec) || ec)
501 {
502 return false;
503 }
504 if(!fs::is_directory(dir, ec) || ec)
505 {
506 return false;
507 }
508
509 constexpr int max_attempts = 100;
510 for(int attempt = 0; attempt < max_attempts; ++attempt)
511 {
512 out = dir / ("." + hpp::to_string(generate_uuid()) + ".temp");
513 fs::error_code exists_ec;
514 const bool exists = fs::exists(out, exists_ec);
515 if(exists_ec)
516 {
517 // Can't probe the path — bail rather than silently looping.
518 ec = exists_ec;
519 return false;
520 }
521 if(!exists)
522 {
523 out.make_preferred();
524 return true;
525 }
526 }
527
528 ec = std::make_error_code(std::errc::file_exists);
529 return false;
530}
531
532//------------------------------------------------------------------------------
533// Atomically copy src -> dst via:
534// 1) copy_file(src, temp)
535// 2) flush temp to disk
536// 3) atomic rename(temp, dst) — retried, since transient locks on Windows
537// (AV, indexers, file watchers) often fail the first call
538//
539// The RAII guard removes the temp file on any early return.
540//------------------------------------------------------------------------------
541auto atomic_copy_file(const fs::path& src, const fs::path& dst, fs::error_code& ec) noexcept -> bool
542{
543 ec.clear();
544
545 // Opportunistic: any previously-locked temp files from this process have
546 // probably been released by now. Try to clean them up before we start a
547 // new write so the directory doesn't accumulate orphans.
548 drain_deferred_cleanup();
549
550 if(!fs::exists(src, ec) || ec)
551 {
552 if(!ec)
553 {
554 ec = std::make_error_code(std::errc::no_such_file_or_directory);
555 }
556 return false;
557 }
558 if(!fs::is_regular_file(src, ec) || ec)
559 {
560 if(!ec)
561 {
562 ec = std::make_error_code(std::errc::invalid_argument);
563 }
564 return false;
565 }
566
567 fs::path temp;
568 if(!make_temp_path(dst.parent_path(), temp, ec))
569 {
570 return false;
571 }
572
573 temp_file_guard guard(temp);
574
575 fs::copy_file(src, temp, fs::copy_options::overwrite_existing, ec);
576 if(ec)
577 {
578 return false;
579 }
580
581 if(!sync_file(temp, ec))
582 {
583 return false;
584 }
585
586 if(!atomic_rename_with_retry(temp, dst, ec))
587 {
588 return false;
589 }
590
591 guard.commit();
592 return true;
593}
594
595void atomic_write_file(const fs::path& dst,
596 const std::function<void(const fs::path&)>& callback,
597 fs::error_code& ec) noexcept
598{
599 ec.clear();
600
601 // Same opportunistic drain as in atomic_copy_file.
602 drain_deferred_cleanup();
603
604 fs::path temp;
605 if(!make_temp_path(dst.parent_path(), temp, ec))
606 {
607 return;
608 }
609
610 temp_file_guard guard(temp);
611
612 callback(temp);
613
614 if(!fs::exists(temp, ec) || ec)
615 {
616 if(!ec)
617 {
618 ec = std::make_error_code(std::errc::no_such_file_or_directory);
619 }
620 return;
621 }
622
623 if(!sync_file(temp, ec))
624 {
625 return;
626 }
627
628 if(!atomic_rename_with_retry(temp, dst, ec))
629 {
630 return;
631 }
632
633 guard.commit();
634}
635
636//------------------------------------------------------------------------------
637// Scan `dir` for orphaned `.<UUID>.temp` files older than `min_age` and remove
638// them. Skips files that look newer than `min_age` so we don't race with a
639// concurrent atomic write in another process.
640//------------------------------------------------------------------------------
641auto cleanup_stale_temp_files(const fs::path& dir,
642 bool recursive,
643 std::chrono::seconds min_age) noexcept -> std::size_t
644{
645 // Give the in-process deferred queue a final chance — files still queued
646 // from earlier writes in this session are usually unlocked by now and we'd
647 // rather not leave them as "stale" leftovers for the next startup.
648 drain_deferred_cleanup();
649
650 fs::error_code ec;
651 if(!fs::exists(dir, ec) || ec)
652 {
653 return 0;
654 }
655 if(!fs::is_directory(dir, ec) || ec)
656 {
657 return 0;
658 }
659
660 const auto now = fs::file_time_type::clock::now();
661 std::size_t removed = 0;
662
663 auto walk = [&](const auto& begin, const auto& end) -> void
664 {
665 for(auto it = begin; it != end; ++it)
666 {
667 const auto& entry = *it;
668 fs::error_code is_file_ec;
669 if(entry.is_regular_file(is_file_ec) && !is_file_ec)
670 {
671 try_remove_stale_temp(entry.path(), now, min_age, removed);
672 }
673 }
674 };
675
676 fs::error_code iter_ec;
677 if(recursive)
678 {
679 walk(fs::recursive_directory_iterator(dir, iter_ec), fs::recursive_directory_iterator{});
680 }
681 else
682 {
683 walk(fs::directory_iterator(dir, iter_ec), fs::directory_iterator{});
684 }
685
686 return removed;
687}
688
689} // namespace asset_writer
690} // namespace unravel
std::string name
Definition hub.cpp:33
#define APPLOG_WARNING(...)
Definition logging.h:19
#define APPLOG_INFO(...)
Definition logging.h:18
#define APPLOG_TRACE(...)
Definition logging.h:17
auto make_temp_path(const fs::path &dir, fs::path &out, fs::error_code &ec) noexcept -> bool
auto atomic_rename_file(const fs::path &src, const fs::path &dst, fs::error_code &ec) noexcept -> bool
auto cleanup_stale_temp_files(const fs::path &dir, bool recursive, std::chrono::seconds min_age) noexcept -> std::size_t
auto atomic_copy_file(const fs::path &src, const fs::path &dst, fs::error_code &ec) noexcept -> bool
auto sync_file(const fs::path &temp, fs::error_code &ec) noexcept -> bool
void atomic_write_file(const fs::path &dst, const std::function< void(const fs::path &)> &callback, fs::error_code &ec) noexcept
auto generate_uuid() -> hpp::uuid
Definition uuid.cpp:25