105 lines
4.4 KiB
Swift
105 lines
4.4 KiB
Swift
import Foundation
|
|
import Darwin
|
|
|
|
public enum AtomicFile {
|
|
/// Writes `data` to `url` durably: writes to `<url>.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<T: Encodable>(_ 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)))")
|
|
}
|
|
}
|
|
}
|