Files
xtool-mirror/Sources/XUtils/TemporaryDirectory.swift
2026-06-22 02:14:23 -04:00

185 lines
7.1 KiB
Swift

import Foundation
package struct TemporaryDirectory: ~Copyable {
private static let debugTmp = ProcessInfo.processInfo.environment["XTL_DEBUG_TMP"] != nil
private var shouldDelete: Bool
private let base: URL
package let url: URL
/// Prepares a fresh tmpdir root.
///
/// Optional, but try calling this at launch to clean up old resources.
package static func prepare() {
_ = TemporaryDirectoryRoot.shared
}
/// Creates a temporary directory where `lastPathComponent` is exactly `name`.
///
/// The directory is deleted on deinit or (if the object never deinits) on next launch.
/// To save the contents, move them elsewhere with ``persist(at:)``.
package init(name: String) throws {
do {
let basename = name
.replacingOccurrences(of: ".", with: "_")
.replacingOccurrences(of: "/", with: "_")
.prefix(32)
self.base = try TemporaryDirectoryRoot.shared.url
// ensures uniqueness
.appendingPathComponent("dir-\(basename)-\(UUID().uuidString)")
self.url = base.appendingPathComponent(name, isDirectory: true)
self.shouldDelete = true
} catch {
// non-copyable types can't be partially initialized so we need a stub value
self.base = URL(fileURLWithPath: "")
self.url = base
self.shouldDelete = false
throw error
}
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
if Self.debugTmp {
stderrPrint("Created TemporaryDirectory: \(url.path)")
}
}
private func _delete() {
guard !Self.debugTmp else { return }
try? FileManager.default.removeItem(at: base)
}
package consuming func persist(at location: URL) throws {
try FileManager.default.moveItem(at: url, to: location)
_delete()
// we do this after moving, so that if the move fails we clean up
shouldDelete = false
}
deinit {
if shouldDelete { _delete() }
}
}
private struct TemporaryDirectoryRoot {
static let shared = TemporaryDirectoryRoot()
private let _url: Result<URL, Errors>
var url: URL {
get throws(Errors) {
try _url.get()
}
}
private init() {
let url: URL
let env = ProcessInfo.processInfo.environment
if let tmpdir = env["XTL_TMPDIR"] ?? env["TMPDIR"] {
url = URL(fileURLWithPath: tmpdir).appendingPathComponent("sh.xtool")
} else {
#if os(Linux)
// On Linux, /tmp is commonly a tmpfs mount while ~/.swiftpm lives on ext4.
// Foundation's FileManager.copyItem uses sendfile(2) internally, which returns
// EINVAL when copying between different filesystem types (e.g. tmpfs → ext4).
// This causes `swift sdk install` to fail on distros like Linux Mint (#181).
// Using a cache dir on the home filesystem avoids the cross-fs copy entirely.
let xdgCache = env["XDG_CACHE_HOME"].map { URL(fileURLWithPath: $0) }
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".cache")
url = xdgCache.appendingPathComponent("xtool")
#else
url = FileManager.default.temporaryDirectory.appendingPathComponent("sh.xtool")
#endif
}
Self.pruneOrphans(in: url)
self._url = Result {
let childDir = try Self.claimDirectory(in: url)
try FileManager.default.createDirectory(at: childDir, withIntermediateDirectories: true)
return childDir
}
.mapError { $0 as? Errors ?? .tmpdirCreationFailed(url, $0) }
}
private static func claimDirectory(in url: URL) throws -> URL {
// create a lockfile + tmpdir. while we hold the lock, other instaces of
// xtool will not delete the directory during their prune step.
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
let pid = ProcessInfo.processInfo.processIdentifier
for i in 0..<10 {
let random = "pid-\(pid)-\(i == 0 ? "0" : UUID().uuidString)"
let lockFile = url.appending(path: "\(random).lock")
#if os(macOS)
let fd = try FileDescriptor.open(
FilePath(lockFile.path),
.writeOnly,
options: [.create, .exclusiveLock],
permissions: [.ownerReadWrite, .groupRead, .otherRead],
)
#else
let fd = try FileDescriptor.open(
FilePath(lockFile.path),
.writeOnly,
options: [.create],
permissions: [.ownerReadWrite, .groupRead, .otherRead],
)
guard try fd.tryLock(mode: .exclusive) else {
// someone else raced us and claimed the right to prune between when we created the file and
// when we tried to lock it
continue
}
#endif
// leak the file descriptor so that the lock is held until the process exits
return url.appending(path: random)
}
throw Errors.tmpdirClaimFailed(url)
}
private static func pruneOrphans(in url: URL) {
guard let children = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)
else { return }
for lock in children {
do {
let basename = lock.lastPathComponent
if basename.hasPrefix("tmp-") {
// legacy tmpdir, remove it
try? FileManager.default.removeItem(at: lock)
continue
}
guard basename.hasPrefix("pid-") && basename.hasSuffix(".lock") else { continue }
let lockFD = try FileDescriptor.open(FilePath(lock.path), .readWrite)
defer { try? lockFD.close() }
if try lockFD.tryLock(mode: .exclusive) {
// we must remove the directory first. if we instead removed the lock first,
// we could be killed after the lock was removed but before the dir was
// removed and therefore leave it hanging around.
do {
try FileManager.default.removeItem(at: lock.deletingPathExtension())
} catch CocoaError.fileNoSuchFile {
// pass
}
try FileManager.default.removeItem(at: lock)
}
} catch {
// continue
}
}
}
enum Errors: Error, CustomStringConvertible {
case tmpdirCreationFailed(URL, Error)
case tmpdirClaimFailed(URL)
var description: String {
switch self {
case let .tmpdirCreationFailed(url, error):
"Could not create temporary directory in '\(url.path)': \(error)"
case let .tmpdirClaimFailed(url):
"Could not claim temporary directory in '\(url.path)'"
}
}
}
}