diff --git a/Package.swift b/Package.swift index 01fe34fb..9446d21a 100644 --- a/Package.swift +++ b/Package.swift @@ -92,7 +92,12 @@ let package = Package( exclude: ["openapi-generator-config.yaml", "patch.js"] ), // common utilities shared across xtool targets - .target(name: "XUtils"), + .target( + name: "XUtils", + dependencies: [ + .product(name: "SystemPackage", package: "swift-system"), + ] + ), .target( name: "XKit", dependencies: [ diff --git a/Sources/XUtils/System+Utils.swift b/Sources/XUtils/System+Utils.swift new file mode 100644 index 00000000..df47da9f --- /dev/null +++ b/Sources/XUtils/System+Utils.swift @@ -0,0 +1,53 @@ +import Foundation + +#if canImport(System) +import System + +public typealias FilePath = System.FilePath +public typealias FileDescriptor = System.FileDescriptor +public typealias Errno = System.Errno +#else +import SystemPackage + +public typealias FilePath = SystemPackage.FilePath +public typealias FileDescriptor = SystemPackage.FileDescriptor +public typealias Errno = SystemPackage.Errno + +extension URL { + public init?(filePath: FilePath) { + self.init(filePath: filePath.string) + } +} + +extension FilePath { + public init?(_ url: URL) { + guard url.isFileURL else { return nil } + self.init(url.path) + } +} +#endif + +extension FileDescriptor { + enum LockMode { + case shared + case exclusive + + fileprivate var raw: CInt { + switch self { + case .shared: LOCK_SH + case .exclusive: LOCK_EX + } + } + } + + func tryLock(mode: LockMode) throws -> Bool { + if flock(rawValue, mode.raw | LOCK_NB) == 0 { + return true + } + let err = errno + if err == EWOULDBLOCK { + return false + } + throw Errno(rawValue: err) + } +} diff --git a/Sources/XUtils/TemporaryDirectory.swift b/Sources/XUtils/TemporaryDirectory.swift index d3206c8d..fdd32952 100644 --- a/Sources/XUtils/TemporaryDirectory.swift +++ b/Sources/XUtils/TemporaryDirectory.swift @@ -4,6 +4,7 @@ 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. @@ -19,15 +20,19 @@ package struct TemporaryDirectory: ~Copyable { /// To save the contents, move them elsewhere with ``persist(at:)``. package init(name: String) throws { do { - let basename = name.replacingOccurrences(of: ".", with: "_") - self.url = try TemporaryDirectoryRoot.shared.url + let basename = name + .replacingOccurrences(of: ".", with: "_") + .replacingOccurrences(of: "/", with: "_") + .prefix(32) + self.base = try TemporaryDirectoryRoot.shared.url // ensures uniqueness - .appendingPathComponent("tmp-\(basename)-\(UUID().uuidString)") - .appendingPathComponent(name, isDirectory: true) + .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.url = URL(fileURLWithPath: "") + self.base = URL(fileURLWithPath: "") + self.url = base self.shouldDelete = false throw error } @@ -40,11 +45,12 @@ package struct TemporaryDirectory: ~Copyable { private func _delete() { guard !Self.debugTmp else { return } - try? FileManager.default.removeItem(at: url) + 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 } @@ -83,23 +89,95 @@ private struct TemporaryDirectoryRoot { url = FileManager.default.temporaryDirectory.appendingPathComponent("sh.xtool") #endif } - try? FileManager.default.removeItem(at: url) - do { - try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) - } catch { - self._url = .failure(Errors.tmpdirCreationFailed(url, error)) - return + + 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 + } } - self._url = .success(url) } 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 at '\(url.path)': \(error)" + "Could not create temporary directory in '\(url.path)': \(error)" + case let .tmpdirClaimFailed(url): + "Could not claim temporary directory in '\(url.path)'" } } }