Skip to content

Update FileLock API #2036

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
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
59 changes: 50 additions & 9 deletions Sources/Basic/Lock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,26 +31,55 @@ enum ProcessLockError: Swift.Error {
case unableToAquireLock(errno: Int32)
}

/// Provides functionality to aquire a lock on a file via POSIX's flock() method.
/// Provides functionality to aquire an exclusive lock on a file via POSIX's flock() method.
/// It can be used for things like serializing concurrent mutations on a shared resource
/// by mutiple instances of a process. The `FileLock` is not thread-safe.
public final class FileLock {
private typealias FileDescriptor = CInt

/// File descriptor to the lock file.
#if os(Windows)
private var handle: HANDLE?
#else
private var fileDescriptor: CInt?
private var fileDescriptor: FileDescriptor?
#endif

/// Path to the lock file.
private let lockFile: AbsolutePath
public let path: AbsolutePath

/// Create an instance of process lock with a name and the path where
/// the lock file can be created.
///
/// Note: The cache path should be a valid directory.
public init(name: String, cachePath: AbsolutePath) {
self.lockFile = cachePath.appending(component: name + ".lock")
public init(name: String, in directory: AbsolutePath) {
self.path = directory.appending(component: name)
}

/// Attempts to acquire a lock and immediately returns a Boolean value
/// that indicates whether the attempt was successful.
///
/// - Returns: `true` if the lock was acquired, otherwise `false`.
public func `try`() -> Bool {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This won't compile on windows so should guard it with #if os() checks.

// Open the lock file.
if self.fileDescriptor == nil, let fileDescriptor = try? openFile(at: path) {
self.fileDescriptor = fileDescriptor
}

guard let fileDescriptor = self.fileDescriptor else { return false }

// Aquire lock on the file.
while flock(fileDescriptor, LOCK_EX | LOCK_NB) != 0 {
switch errno {
case EWOULDBLOCK: // non-blocking lock not available
return false
case EINTR: // Retry if interrupted.
continue
default:
return false
}
}

return true
}

/// Try to aquire a lock. This method will block until lock the already aquired by other process.
Expand Down Expand Up @@ -85,19 +114,22 @@ public final class FileLock {
#else
// Open the lock file.
if fileDescriptor == nil {
let fd = SPMLibc.open(lockFile.pathString, O_WRONLY | O_CREAT | O_CLOEXEC, 0o666)
let fd = SPMLibc.open(path.pathString, O_WRONLY | O_CREAT | O_CLOEXEC, 0o666)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you update this to use the new openFile(at:) helper?

if fd == -1 {
throw FileSystemError(errno: errno)
}
self.fileDescriptor = fd
}

// Aquire lock on the file.
while true {
if flock(fileDescriptor!, LOCK_EX) == 0 {
break
}
// Retry if interrupted.
if errno == EINTR { continue }
if errno == EINTR {
continue
}
throw ProcessLockError.unableToAquireLock(errno: errno)
}
#endif
Expand Down Expand Up @@ -129,8 +161,17 @@ public final class FileLock {

/// Execute the given block while holding the lock.
public func withLock<T>(_ body: () throws -> T) throws -> T {
try lock()
defer { unlock() }
try self.lock()
defer { self.unlock() }
return try body()
}

private func openFile(at path: AbsolutePath) throws -> FileDescriptor {
// Open the lock file.
let fd = SPMLibc.open(path.pathString, O_WRONLY | O_CREAT | O_CLOEXEC, 0o666)
if fd == -1 {
throw FileSystemError(errno: errno)
}
return fd
}
}
81 changes: 56 additions & 25 deletions Sources/TestSupportExecutable/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,28 @@ import SPMUtility
/// - cacheDir: The cache directory where lock file should be created.
/// - path: Path to a file which will be mutated.
/// - content: Integer that should be added in that file.
func fileLockTest(cacheDir: AbsolutePath, path: AbsolutePath, content: Int) throws {
let lock = FileLock(name: "TestLock", cachePath: cacheDir)
func fileLockTest(name: String, lockDirectory: AbsolutePath, contentPath: AbsolutePath, content: Int) throws {
let lock = FileLock(name: name, in: lockDirectory)
try lock.withLock {
// Get thr current contents of the file if any.
let currentData: Int
if localFileSystem.exists(path) {
currentData = Int(try localFileSystem.readFileContents(path).description) ?? 0
if localFileSystem.exists(contentPath) {
currentData = Int(try localFileSystem.readFileContents(contentPath).description) ?? 0
} else {
currentData = 0
}
// Sum and write back to file.
try localFileSystem.writeFileContents(path, bytes: ByteString(encodingAsUTF8: String(currentData + content)))
try localFileSystem.writeFileContents(contentPath, bytes: ByteString(encodingAsUTF8: String(currentData + content)))
}
}

func fileLockLock(name: String, in directory: AbsolutePath, duration sleepValue: Int) throws {
let lock = FileLock(name: name, in: directory)
try lock.lock()
SPMLibc.sleep(UInt32(sleepValue))
lock.unlock()
}

class HandlerTest {
let interruptHandler: InterruptHandler

Expand All @@ -47,18 +54,20 @@ class HandlerTest {

enum Mode: String {
case fileLockTest
case fileLockLock
case interruptHandlerTest
case pathArgumentTest
case help
}

struct Options {
struct FileLockOptions {
let cacheDir: AbsolutePath
let path: AbsolutePath
let content: Int
var name: String?
var lockDirectory: AbsolutePath?
var contentPath: AbsolutePath?
var value: Int?
}
var fileLockOptions: FileLockOptions?
var fileLockOptions = FileLockOptions()
var temporaryFile: AbsolutePath?
var absolutePath: AbsolutePath?
var mode = Mode.help
Expand All @@ -71,18 +80,30 @@ do {
usage: "subcommand",
overview: "Test support executable")

let fileLockParser = parser.add(subparser: Mode.fileLockTest.rawValue, overview: "Execute the file lock test")

binder.bindPositional(
fileLockParser.add(positional: "cache directory", kind: String.self, usage: "Path to cache directory"),
fileLockParser.add(positional: "file path", kind: String.self, usage: "Path of the file to mutate"),
fileLockParser.add(positional: "contents", kind: Int.self, usage: "Contents to write in the file"),
to: {
$0.fileLockOptions = Options.FileLockOptions(
cacheDir: AbsolutePath($1),
path: AbsolutePath($2),
content: $3)
})
let fileLockTestParser = parser.add(subparser: Mode.fileLockTest.rawValue, overview: "Execute the file lock test")
binder.bind(positional: fileLockTestParser.add(positional: "lock name", kind: String.self, usage: "File lock name"), to: { (options, value) in
options.fileLockOptions.name = value
})
binder.bind(positional: fileLockTestParser.add(positional: "lock directory", kind: String.self, usage: "Path to lock directory"), to: { (options, value) in
options.fileLockOptions.lockDirectory = AbsolutePath(value)
})
binder.bind(positional: fileLockTestParser.add(positional: "file path", kind: String.self, usage: "Path of the file to mutate"), to: { (options, value) in
options.fileLockOptions.contentPath = AbsolutePath(value)
})
binder.bind(positional: fileLockTestParser.add(positional: "content", kind: Int.self, usage: "Contents to write in the file"), to: { (options, value) in
options.fileLockOptions.value = value
})

let fileLockLockParser = parser.add(subparser: Mode.fileLockLock.rawValue, overview: "Execute the file lock test")
binder.bind(positional: fileLockLockParser.add(positional: "lock name", kind: String.self, usage: "File lock name"), to: { (options, value) in
options.fileLockOptions.name = value
})
binder.bind(positional: fileLockLockParser.add(positional: "lock directory", kind: String.self, usage: "Path to lock directory"), to: { (options, value) in
options.fileLockOptions.lockDirectory = AbsolutePath(value)
})
binder.bind(positional: fileLockLockParser.add(positional: "duration", kind: Int.self, usage: "Lock duration"), to: { (options, value) in
options.fileLockOptions.value = value
})

let intHandlerParser = parser.add(
subparser: Mode.interruptHandlerTest.rawValue,
Expand Down Expand Up @@ -111,11 +132,21 @@ do {

switch options.mode {
case .fileLockTest:
guard let fileLockOptions = options.fileLockOptions else { break }
let fileLockOptions = options.fileLockOptions
guard let name = fileLockOptions.name, let lockDirectory = fileLockOptions.lockDirectory,
let contentPath = fileLockOptions.contentPath, let value = fileLockOptions.value else { break }
try fileLockTest(
cacheDir: fileLockOptions.cacheDir,
path: fileLockOptions.path,
content: fileLockOptions.content)
name: name,
lockDirectory: lockDirectory,
contentPath: contentPath,
content: value)
case .fileLockLock:
let fileLockOptions = options.fileLockOptions
guard let name = fileLockOptions.name, let lockDirectory = fileLockOptions.lockDirectory,
let value = fileLockOptions.value else { break }
try fileLockLock(name: name,
in: lockDirectory,
duration: value)
case .interruptHandlerTest:
let handlerTest = try HandlerTest(options.temporaryFile!)
handlerTest.run()
Expand Down
24 changes: 23 additions & 1 deletion Tests/BasicTests/LockTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,34 @@ class LockTests: XCTestCase {
let N = 10
let threads = (1...N).map { idx in
return Thread {
_ = try! SwiftPMProduct.TestSupportExecutable.execute(["fileLockTest", tempDir.path.pathString, sharedResource.path.pathString, String(idx)])
_ = try! SwiftPMProduct.TestSupportExecutable.execute(["fileLockTest", "test.lock", tempDir.path.pathString, sharedResource.path.pathString, String(idx)])
}
}
threads.forEach { $0.start() }
threads.forEach { $0.join() }

XCTAssertEqual(try localFileSystem.readFileContents(sharedResource.path).description, String((N * (N + 1) / 2 )))
}

func testFileLockTry() throws {
let tempDir = try TemporaryDirectory(removeTreeOnDeinit: true)
let fileLock = FileLock(name: "test.lock", in: tempDir.path)

let lockThread = Thread {
try! SwiftPMProduct.TestSupportExecutable.execute(["fileLockLock", "test.lock", tempDir.path.pathString, "3"])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the trivial implementation, I don't think it's worth adding a test case that will sleep for 3 seconds. It seems fine to land this PR without a test for the new try method.

}

lockThread.start()

// wait for the fileLockLock process to launch and acquire a lock
XCTAssertTrue(waitForFile(tempDir.path.appending(component: "test.lock")))
// try lock
XCTAssertFalse(fileLock.try())
// wait for lock
try fileLock.lock()
fileLock.unlock()
XCTAssertTrue(fileLock.try())

lockThread.join()
}
}
1 change: 1 addition & 0 deletions Tests/BasicTests/XCTestManifests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ extension LockTests {
static let __allTests__LockTests = [
("testBasics", testBasics),
("testFileLock", testFileLock),
("testFileLockTry", testFileLockTry),
]
}

Expand Down
1 change: 1 addition & 0 deletions Tests/WorkspaceTests/XCTestManifests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ extension WorkspaceTests {
("testToolsVersionRootPackages", testToolsVersionRootPackages),
("testTransitiveDependencySwitchWithSameIdentity", testTransitiveDependencySwitchWithSameIdentity),
("testUpdate", testUpdate),
("testWorkspaceResolutionKind", testWorkspaceResolutionKind),
]
}

Expand Down