Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
53 changes: 53 additions & 0 deletions Sources/XUtils/System+Utils.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
106 changes: 92 additions & 14 deletions Sources/XUtils/TemporaryDirectory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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)'"
}
}
}
Expand Down