import Foundation import Darwin public enum AtomicFile { /// Writes `data` to `url` durably: writes to `.tmp` in the SAME directory as `url`, /// fsyncs that file descriptor, closes it, rename()s it onto `url` (atomic same-volume /// rename), then opens `url`'s containing directory and fsyncs THAT too (a rename is only /// durable once its directory entry is flushed). Never uses `Data.write(to:)` — that call /// does not fsync. public static func write(_ data: Data, to url: URL) throws { let finalPath = url.path let directoryURL = url.deletingLastPathComponent() let tmpURL = directoryURL.appendingPathComponent(url.lastPathComponent + ".tmp") let tmpPath = tmpURL.path // Clear a stale .tmp left by a previous crash. ENOENT (nothing to clear) is fine. if unlink(tmpPath) != 0 && errno != ENOENT { throw ShotdeckError.spoolWriteFailed( path: finalPath, underlying: "could not clear a stale temp file: \(String(cString: strerror(errno)))") } let fd = open(tmpPath, O_WRONLY | O_CREAT | O_TRUNC, 0o644) guard fd >= 0 else { throw ShotdeckError.spoolWriteFailed( path: finalPath, underlying: "open failed: \(String(cString: strerror(errno)))") } var writeFailure: String? data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in var remaining = raw.count var pointer = raw.baseAddress while remaining > 0 { let n = Darwin.write(fd, pointer, remaining) if n < 0 { if errno == EINTR { continue } writeFailure = "write failed: \(String(cString: strerror(errno)))" break } if n == 0 { break } remaining -= n pointer = pointer?.advanced(by: n) } } if let writeFailure { close(fd) _ = unlink(tmpPath) throw ShotdeckError.spoolWriteFailed(path: finalPath, underlying: writeFailure) } if fsync(fd) != 0 { let message = "fsync failed: \(String(cString: strerror(errno)))" close(fd) _ = unlink(tmpPath) throw ShotdeckError.spoolWriteFailed(path: finalPath, underlying: message) } if close(fd) != 0 { _ = unlink(tmpPath) throw ShotdeckError.spoolWriteFailed( path: finalPath, underlying: "close failed: \(String(cString: strerror(errno)))") } if rename(tmpPath, finalPath) != 0 { let message = "rename failed: \(String(cString: strerror(errno)))" _ = unlink(tmpPath) throw ShotdeckError.spoolWriteFailed(path: finalPath, underlying: message) } try fsyncDirectory(at: directoryURL) } /// Encodes `value` with `JSONEncoder` (`.sortedKeys, .prettyPrinted`, /// `.dateEncodingStrategy = .iso8601`) and writes it through `write(_:to:)`. public static func writeJSON(_ value: T, to url: URL) throws { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys, .prettyPrinted] encoder.dateEncodingStrategy = .iso8601 let data: Data do { data = try encoder.encode(value) } catch { throw ShotdeckError.spoolWriteFailed( path: url.path, underlying: "JSON encoding failed: \(error.localizedDescription)") } try write(data, to: url) } /// Opens `url` (must be an existing directory) and fsyncs it. Used after any directory- /// level `rename()` (moving/renaming a whole session directory) — the same durability /// requirement as the internal directory-fsync inside `write(_:to:)`, exposed for callers /// that rename directories themselves (SpoolStore). public static func fsyncDirectory(at url: URL) throws { let fd = open(url.path, O_RDONLY | O_DIRECTORY) guard fd >= 0 else { throw ShotdeckError.spoolWriteFailed( path: url.path, underlying: "could not open directory for fsync: \(String(cString: strerror(errno)))") } let result = fsync(fd) close(fd) if result != 0 { throw ShotdeckError.spoolWriteFailed( path: url.path, underlying: "directory fsync failed: \(String(cString: strerror(errno)))") } } }