Files
xtool-mirror/Sources/XUtils/TemporaryDirectory.swift
Kabir Oberai 7f151a9c66 Fix tmpdir reentrancy (#234)
Previously, we would clear xtool's temporary directory on startup. This
(practically) meant you couldn't have multiple instances running in
parallel, because the second instance could delete the first instance's
temp resources while the first instance still needed them. Fix this by
having xtool instances declare locks on their temporary directories.
2026-06-22 02:22:42 -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)'"
}
}
}
}