Add support for strict swift concurrency (#21)

This commit is contained in:
Paolo Di Lorenzo
2025-05-30 21:57:51 -04:00
committed by GitHub
14 changed files with 386 additions and 192 deletions
+6 -3
View File
@@ -1,4 +1,4 @@
// swift-tools-version: 5.9
// swift-tools-version: 6.0
import PackageDescription
@@ -10,7 +10,8 @@ let package = Package(
.iOS(.v16),
.watchOS(.v9),
.macOS(.v13),
.tvOS(.v16)
.tvOS(.v16),
.visionOS(.v1)
],
products: [
.library(
@@ -21,7 +22,9 @@ let package = Package(
targets: [
.target(
name: "ChessKitEngine",
dependencies: ["ChessKitEngineCore"]
dependencies: [
"ChessKitEngineCore",
]
),
.target(
name: "ChessKitEngineCore",
+21 -22
View File
@@ -22,8 +22,8 @@ For a related Swift package that manages chess logic, see [chesskit-swift](https
## Usage
1. Add `chesskit-engine` as a dependency
* in an [app built in Xcode](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app),
* or [as a dependency to another Swift Package](https://www.swift.org/documentation/package-manager/#importing-dependencies).
* in an [app built in Xcode](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app),
* or [as a dependency to another Swift Package](https://www.swift.org/documentation/package-manager/#importing-dependencies).
2. Next, import `ChessKitEngine` to use it in Swift code:
``` swift
@@ -37,35 +37,33 @@ import ChessKitEngine
## Features
* Initialize an engine and set response handler
* Initialize an engine and set response stream
``` swift
// create Stockfish engine
let engine = Engine(type: .stockfish)
// set response handler, called when engine issues responses
engine.receiveResponse = { response in
// set response stream, called when engine issues responses
for await response in await engine.responseStream! {
print(response)
}
// start listening for engine responses
engine.start {
// engine is ready to go!
}
engine.start()
```
* Send [UCI protocol](https://backscattering.de/chess/uci/2006-04.txt) commands
``` swift
// check that engine is running before sending commands
guard engine.isRunning else { return }
guard await engine.isRunning else { return }
// stop any current engine processing
engine.send(command: .stop)
await engine.send(command: .stop)
// set engine position to standard starting chess position
engine.send(command: .position(.startpos))
await engine.send(command: .position(.startpos))
// start engine analysis with maximum depth of 15
engine.send(command: .go(depth: 15))
await engine.send(command: .go(depth: 15))
```
* Update engine position after a move is made
@@ -73,15 +71,15 @@ engine.send(command: .go(depth: 15))
// FEN after 1. e4
let newPosition = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
engine.send(command: .stop)
engine.send(command: .position(.fen(newPosition)))
engine.send(command: .go(depth: 15))
await engine.send(command: .stop)
await engine.send(command: .position(.fen(newPosition)))
await engine.send(command: .go(depth: 15))
```
* Receive engine's analysis of current position
``` swift
// receiveResponse is called whenever the engine publishes a response
engine.receiveResponse = { response in
// responseStream is called whenever the engine publishes a response
for await response in await engine.responseStream! {
switch response {
case let .info(info):
print(info.score) // engine evaluation score in centipawns
@@ -90,18 +88,19 @@ engine.receiveResponse = { response in
break
}
}
```
* Terminate engine communication
``` swift
// stop listening for engine responses
engine.stop()
await engine.stop()
```
* Enable engine response logging
``` swift
// log engine commands and responses to the console
engine.loggingEnabled = true
engine.setLoggingEnabled(true)
// Logging is off by default since engines can be very
// verbose while analyzing positions and returning evaluations.
@@ -116,11 +115,11 @@ They can be provided to the engine using the `.setoption(id:value:)` UCI command
For example:
``` swift
// Stockfish
engine.send(command: .setoption(id: "EvalFile", value: fileURL))
engine.send(command: .setoption(id: "EvalFileSmall", value: smallFileURL))
await engine.send(command: .setoption(id: "EvalFile", value: fileURL))
await engine.send(command: .setoption(id: "EvalFileSmall", value: smallFileURL))
// Lc0
engine.send(command: .setoption(id: "WeightsFile", value: fileURL))
await engine.send(command: .setoption(id: "WeightsFile", value: fileURL))
```
The following details the recommended files for each engine and where to obtain them.
+204 -104
View File
@@ -5,177 +5,277 @@
import ChessKitEngineCore
public class Engine {
public final class Engine: Sendable {
//MARK: - Public properties
/// The type of the engine.
public let type: EngineType
/// Messenger used to communicate with engine.
private let messenger: EngineMessenger
/// Whether the engine is currently running.
///
/// - To start the engine, call ``start(coreCount:multipv:)``.
/// - To stop the engine, call ``stop()``.
///
/// Engine must be running for ``send(command:)`` to work.
public var isRunning: Bool {
get async { await engineConfigurationActor.isRunning }
}
/// Whether logging should be enabled.
///
/// If set to `true`, engine commands and responses
/// will be logged to the console. The default value is
/// `false`.
public var loggingEnabled = false
/// Whether the engine is currently running.
///
/// - To start the engine, call `start()`.
/// - To stop the engine, call `stop()`.
/// Can be set via ``setLoggingEnabled(_:)`` function.
public var loggingEnabled: Bool {
get async { await engineConfigurationActor.loggingEnabled }
}
/// an AsyncStream that is called when engine responses are received.
///
/// Engine must be running for `send(command:)` to work.
public private(set) var isRunning = false
private var startupLoop: EngineSetupLoop
/// The underlying value ``EngineResponse`` contains the engine
/// response corresponding to the UCI protocol.
public var responseStream : AsyncStream<EngineResponse>? {
get async { await engineConfigurationActor.asyncStream }
}
//MARK: - Private properties
///Actor used to hold mutating data in a thread safe environment.
private let engineConfigurationActor: EngineConfiguration
/// Messenger used to communicate with engine.
private let messenger: EngineMessenger
private let queue = DispatchQueue(
label: "ck-engine-queue",
qos: .userInteractive
)
/// Initializes an engine with the provided `type`.
//MARK: - Life cycle functions
/// Initializes an engine with the provided ``EngineType`` and optional logging enabled flag.
///
/// - parameter type: The type of engine to use.
///
public init(type: EngineType) {
/// - parameter loggingEnabled: If set to `true`, engine commands and responses
/// will be logged to the console. The default value is `false`.
public init(type: EngineType, loggingEnabled: Bool = false) {
self.type = type
messenger = EngineMessenger(engineType: type.objc)
startupLoop = DefaultEngineSetupLoop()
self.messenger = EngineMessenger(engineType: type.objc)
self.engineConfigurationActor = EngineConfiguration(loggingEnabled: loggingEnabled)
}
deinit {
stop()
}
// This no longer work in an async environment as stop function outlives the deinit function.
// Support for async deinit should be added in a future version of Swift (6.1)
// https://github.com/swiftlang/swift-evolution/blob/main/proposals/0371-isolated-synchronous-deinit.md
// deinit {
// stop()
// }
//MARK: - Public functions
/// Starts the engine.
///
/// You must call this function and wait for ``EngineResponse/readyok``
/// before you can ask the engine to perform any work.
///
/// - parameter coreCount: The number of processor cores to use for engine
/// calculation. The default value is `nil` which uses the number of
/// cores available on the device.
/// - parameter multipv: The number of lines the engine should return,
/// sent via the `"MultiPV"` UCI option.
/// - parameter completion: The completion handler that is called when
/// the engine setup is complete. You must wait for this to be called
/// before sending further commands to the engine.
///
/// This must be called before sending any commands
/// with `send(command:)`.
public func start(
coreCount: Int? = nil,
multipv: Int = 1,
completion: @escaping () -> Void = {}
) {
startupLoop.startupDidComplete = {
self.isRunning = true
self.performInitialSetup(
coreCount: coreCount ?? ProcessInfo.processInfo.processorCount,
multipv: multipv
)
DispatchQueue.main.async {
completion()
}
}
messenger.responseHandler = { [weak self] response in
guard let self else { return }
guard let parsed = EngineResponse(rawValue: response) else {
if !response.isEmpty {
self.log(response)
}
return
}
self.log(parsed.rawValue)
if !self.isRunning, let next = startupLoop.nextCommand(given: parsed) {
self.send(command: next)
}
DispatchQueue.main.async {
self.receiveResponse(parsed)
}
}
multipv: Int = 1
) async {
//Setup async stream response if not already set.
await engineConfigurationActor.setAsyncStream()
setMessengerResponseHandler(coreCount: coreCount, multipv: multipv)
messenger.start()
// start engine setup loop
send(command: .uci)
await send(command: .uci)
}
/// Stops the engine.
///
/// Call this to stop all engine calculation and clean up.
/// After calling `stop()`, `start()` must be called before
/// sending any more commands with `send(command:)`.
public func stop() {
guard isRunning else { return }
send(command: .stop)
send(command: .quit)
/// After calling ``stop()``, ``start(coreCount:multipv:)`` must be called before
/// sending any more commands with ``send(command:)``.
///
/// - note: as temporary fix this function must be called before deiniting the engine.
public func stop() async {
guard await isRunning == true else { return }
await send(command: .stop)
await send(command: .quit)
messenger.stop()
isRunning = false
initialSetupComplete = false
await engineConfigurationActor.clearAsyncStream()
await engineConfigurationActor.setIsRunning(isRunning: false)
await engineConfigurationActor.setInitialSetupComplete(initialSetupComplete: false)
}
/// Sends a command to the engine.
///
/// - parameter command: The command to send.
///
/// Commands must be of type `EngineCommand` to ensure
/// validity. While the engine is processing commands or
/// thinking, any responses will be returned via `receiveResponse`.
public func send(command: EngineCommand) {
guard isRunning || [.uci, .isready].contains(command) else {
log("Engine is not running, call start() first.")
/// Commands must be of type ``EngineCommand`` to ensure
/// validity.
///
/// Any responses will be returned via ``responseStream``.
public func send(command: EngineCommand) async {
guard await isRunning || [.uci, .isready].contains(command) else {
await log("Engine is not running, call start() first.")
return
}
await log(command.rawValue)
queue.sync {
log(command.rawValue)
messenger.sendCommand(command.rawValue)
}
}
/// Closure that is called when engine responses are received.
/// Enable printing logs to console.
///
/// - parameter response: The response received from the engine.
/// - parameter loggingEnabled: If set to `true`, engine commands and responses
/// will be logged to the console. The default value is `false`.
///
/// The returned `response` is of type `EngineResponse` which
/// is a type-safe enum corresponding to the UCI protocol.
public var receiveResponse: (_ response: EngineResponse) -> Void = {
_ in
}
// MARK: - Private
/// Logs `message` if `loggingEnabled` is `true`.
private func log(_ message: String) {
if loggingEnabled {
Logging.print(message)
public func setLoggingEnabled(_ loggingEnabled: Bool) {
Task {
await engineConfigurationActor
.setLoggingEnabled(loggingEnabled: loggingEnabled)
}
}
private var initialSetupComplete = false
// MARK: - Private functions
/// Logs `message` if `loggingEnabled` is `true`.
private func log(_ message: String) async {
if await loggingEnabled {
Logging.print(message)
}
}
/// convinience function to set up `messenger.responseHandler`
private func setMessengerResponseHandler(
coreCount: Int? = nil,
multipv: Int = 1
) {
messenger.responseHandler = { [weak self] response in
Task{ [weak self] in
guard let self,
let parsed = EngineResponse(rawValue: response) else {
if !response.isEmpty {
await self?.log(response)
}
return
}
await self.log(parsed.rawValue)
if await !self.isRunning {
if parsed == .readyok {
await self.performInitialSetup(
coreCount: coreCount ?? ProcessInfo.processInfo.processorCount,
multipv: multipv
)
} else if let next = EngineCommand.nextSetupLoopCommand(
given: parsed
) {
await self.send(command: next)
}
}
await self.engineConfigurationActor.streamContinuation?.yield(parsed)
}
}
}
/// Sets initial engine options.
private func performInitialSetup(coreCount: Int, multipv: Int) {
guard !initialSetupComplete else { return }
private func performInitialSetup(coreCount: Int, multipv: Int) async {
guard await !engineConfigurationActor.initialSetupComplete else { return }
await engineConfigurationActor.setIsRunning(isRunning: true)
// configure engine-specific options
type.setupCommands.forEach(send)
for command in type.setupCommands {
await send(command: command)
}
// configure common engine options
send(command: .setoption(
await send(command: .setoption(
id: "Threads",
value: "\(max(coreCount - 1, 1))"
))
send(command: .setoption(id: "MultiPV", value: "\(multipv)"))
await send(command: .setoption(id: "MultiPV", value: "\(multipv)"))
initialSetupComplete = true
await engineConfigurationActor
.setInitialSetupComplete(initialSetupComplete: true)
}
}
//MARK: EngineConfiguration actor
//An actor to hold the configuration for the engine class.
//Since engine now conforms to sendable protocol, we need to
//move the mutable data into async safe environment.
//
fileprivate actor EngineConfiguration: Sendable {
/// Whether the engine is currently running.
private(set) var isRunning = false
/// Whether logging should be enabled.
private(set) var loggingEnabled = false
/// Whether the initial engine setup was completed
private(set) var initialSetupComplete = false
/// An async stream to notify the end user about engine responses
private(set) var asyncStream: AsyncStream<EngineResponse>?
/// A reference to AsyncStream's continuation for later access by `EngineMessenger.responseHandler`
private(set) var streamContinuation: AsyncStream<EngineResponse>.Continuation?
init(loggingEnabled: Bool = false) {
self.loggingEnabled = loggingEnabled
Task{ await setAsyncStream() }
}
func setLoggingEnabled(loggingEnabled: Bool) async {
self.loggingEnabled = loggingEnabled
}
func setInitialSetupComplete(initialSetupComplete: Bool) async {
self.initialSetupComplete = initialSetupComplete
}
func setIsRunning(isRunning: Bool) async {
self.isRunning = isRunning
}
func setAsyncStream() async {
guard self.asyncStream == nil else { return }
self.asyncStream = AsyncStream { (continuation: AsyncStream<EngineResponse>.Continuation) -> Void in
Task{ await setStreamContinuation(continuation) }
}
}
func clearAsyncStream() async {
self.asyncStream = nil
self.streamContinuation = nil
}
private func setStreamContinuation(_ continuation: AsyncStream<EngineResponse>.Continuation?) async {
self.streamContinuation = continuation
}
}
@@ -6,7 +6,7 @@
/// Possible engine commands based on the
/// [Universal Chess Interface (UCI)](https://backscattering.de/chess/uci/2006-04.txt).
///
public enum EngineCommand: Equatable {
public enum EngineCommand: Equatable, Sendable {
/// `"debug [ on | off ]"`
///
@@ -165,4 +165,18 @@ public enum EngineCommand: Equatable {
return "quit"
}
}
internal static func nextSetupLoopCommand(given response: EngineResponse?) -> EngineCommand? {
// engine setup loop
// <uci> <uciok> <isready> <readok> complete
switch response {
case nil:
return .uci
case .uciok:
return .isready
default:
return nil
}
}
}
@@ -7,7 +7,8 @@ public extension EngineCommand {
/// Possible positions that can be passed to
/// `EngineCommand.position`.
enum PositionString: Equatable, RawRepresentable {
enum PositionString: Equatable, RawRepresentable, Sendable {
/// Any FEN position, given in the provided `String`.
case fen(String)
/// The starting position.
@@ -6,8 +6,8 @@
/// Possible engine responses based on the
/// [Universal Chess Interface (UCI)](https://backscattering.de/chess/uci/2006-04.txt).
///
public enum EngineResponse {
public enum EngineResponse: Sendable {
/// `"id name <x>"`, `"id author <x>"`
///
/// See [UCI protocol documentation](https://backscattering.de/chess/uci/2006-04.txt)
@@ -7,7 +7,7 @@ public extension EngineResponse {
/// Possible ID types that can be returned by
/// `EngineResponse.id`.
enum ID {
enum ID: Sendable {
/// The engine's name.
case name(String)
/// The engine's author(s).
@@ -7,7 +7,7 @@
public extension EngineResponse {
struct Info {
struct Info: Sendable {
public var depth: Int?
public var seldepth: Int?
public var time: Int?
@@ -218,7 +218,7 @@ extension EngineResponse.Info: CustomStringConvertible {
// MARK: - Score
public extension EngineResponse.Info {
struct Score {
struct Score: Sendable {
/// The score from the engine's point of view in centipawns.
public var cp: Double?
/// Mate in moves, not plies.
@@ -285,7 +285,7 @@ extension EngineResponse.Info.Score: Equatable {}
// MARK: - CurrLine
extension EngineResponse.Info {
public struct CurrLine {
public struct CurrLine: Sendable {
var cpunr: Int?
var moves: [String]
}
@@ -1,32 +0,0 @@
//
// EngineSetupLoop.swift
// ChessKitEngine
//
protocol EngineSetupLoop {
func nextCommand(given response: EngineResponse?) -> EngineCommand?
var startupDidComplete: () -> Void { get set }
}
struct DefaultEngineSetupLoop: EngineSetupLoop {
var startupDidComplete: () -> Void = {}
func nextCommand(given response: EngineResponse?) -> EngineCommand? {
// engine setup loop
// <uci> <uciok> <isready> <readok> complete
switch response {
case nil:
return .uci
case .uciok:
return .isready
case .readyok:
startupDidComplete()
return nil
default:
return nil
}
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
import ChessKitEngineCore
/// Possible engines available in `ChessKitEngine`.
public enum EngineType: Int {
public enum EngineType: Int, Sendable {
case stockfish
case lc0
@@ -14,6 +14,7 @@
# pragma mark - EngineMessenger
/// Messenger to communicate with the specified chess engine.
NS_SWIFT_SENDABLE
@interface EngineMessenger : NSObject
/// Called whenever a response is received from the engine.
@@ -14,6 +14,7 @@ NSPipe *_readPipe;
NSPipe *_writePipe;
NSFileHandle *_pipeReadHandle;
NSFileHandle *_pipeWriteHandle;
NSLock *_lock;
/// Initializes a new `EngineMessenger` with default engine `Stockfish`.
- (id)init {
@@ -24,6 +25,7 @@ NSFileHandle *_pipeWriteHandle;
self = [super init];
if (self) {
_lock = [[NSLock alloc] init];
switch (type) {
case EngineTypeStockfish:
_engine = new StockfishEngine();
@@ -43,6 +45,7 @@ NSFileHandle *_pipeWriteHandle;
}
- (void)start {
[_lock lock];
// set up read pipe
_readPipe = [NSPipe pipe];
_pipeReadHandle = [_readPipe fileHandleForReading];
@@ -56,7 +59,13 @@ NSFileHandle *_pipeWriteHandle;
object:_pipeReadHandle
];
[_pipeReadHandle readInBackgroundAndNotify];
dispatch_async(dispatch_get_main_queue(), ^{
//This has to run on a thread that has an active run loop
//otherwise we don't get notified when a read occurs.
//Since we are using async, the only active run loop we can
//guarentee to have an active run loop is the main thread.
[_pipeReadHandle readInBackgroundAndNotify];
});
// set up write pipe
_writePipe = [NSPipe pipe];
@@ -69,9 +78,11 @@ NSFileHandle *_pipeWriteHandle;
dispatch_async(_queue, ^{
_engine->initialize();
});
[_lock unlock];
}
- (void)stop {
[_lock lock];
[_pipeReadHandle closeFile];
[_pipeWriteHandle closeFile];
@@ -82,6 +93,7 @@ NSFileHandle *_pipeWriteHandle;
_pipeWriteHandle = NULL;
[[NSNotificationCenter defaultCenter] removeObserver:self];
[_lock unlock];
}
- (void)sendCommand: (NSString*) command {
@@ -22,8 +22,10 @@ import XCTest
///
/// }
/// ```
///
@TestsActor
class BaseEngineTests: XCTestCase {
override class var defaultTestSuite: XCTestSuite {
// Disable tests in base test case with empty XCTestSuite
if self == BaseEngineTests.self {
@@ -32,41 +34,110 @@ class BaseEngineTests: XCTestCase {
return super.defaultTestSuite
}
}
/// The engine type to test.
var engineType: EngineType!
var engine: Engine!
nonisolated(unsafe) var engineType: EngineType!
nonisolated(unsafe) var engine: Engine!
override func setUp() {
super.setUp()
engine = Engine(type: engineType)
}
override func tearDown() {
engine.stop()
override func tearDown() async throws {
await engine.stop()
engine = nil
super.tearDown()
}
func testEngineStart() async {
XCTAssert(!Thread.isMainThread, "Test must be run on a background thread")
XCTAssertNotNil(self.engine, "Failed to initialize engine")
func testEngineSetup() {
let expectation = self.expectation(
description: "Expect engine \(engine.type.name) to start up."
)
await startEngine(expectation: expectation)
await fulfillment(of: [expectation], timeout: 5)
}
func testEngineStop() async {
XCTAssert(!Thread.isMainThread, "Test must be run on a background thread")
XCTAssertNotNil(self.engine, "Failed to initialize engine")
let expectationStartEngine = self.expectation(
description: "Expect engine \(engine.type.name) to start up."
)
let expectationStopEngine = self.expectation(
description: "Expect engine \(engine.type.name) to stop gracefully."
)
await startEngine(expectation: expectationStartEngine)
await stopEngine(expectation: expectationStopEngine)
await fulfillment(of: [expectationStartEngine, expectationStopEngine], timeout: 5)
}
func testEngineRestart() async {
XCTAssert(!Thread.isMainThread, "Test must be run on a background thread")
XCTAssertNotNil(self.engine, "Failed to initialize engine")
engine.receiveResponse = { [weak self] response in
guard let self else { return }
if case let .id(id) = response, case let .name(name) = id {
XCTAssertTrue(name.contains(engine.type.version))
let expectationStartEngine = self.expectation(
description: "Expect engine \(engine.type.name) to start up."
)
let expectationStopEngine = self.expectation(
description: "Expect engine \(engine.type.name) to stop gracefully."
)
expectationStartEngine.expectedFulfillmentCount = 2
await startEngine(expectation: expectationStartEngine)
await stopEngine(expectation: expectationStopEngine)
await startEngine(expectation: expectationStartEngine)
await fulfillment(of: [expectationStartEngine, expectationStopEngine], timeout: 5)
}
internal func stopEngine(expectation: XCTestExpectation) async {
await engine.stop()
if await !engine.isRunning,
await engine.responseStream == nil {
expectation.fulfill()
}
}
internal func startEngine(expectation: XCTestExpectation) async {
await engine.start()
for await response in await engine.responseStream! {
if case let .id(id) = response,
case let .name(name) = id {
let version = engine.type.version
XCTAssertTrue(name.contains(version))
}
if response == .readyok {
let isRunning = await engine.isRunning
if response == .readyok &&
isRunning {
expectation.fulfill()
break
}
}
engine.start()
wait(for: [expectation], timeout: 5)
}
}
//This actor's purpose is to ensure tests for the engine
//class aren't running on main thread.
//Since [EngineMessenger start] function now uses
//`dispatch_async(dispatch_get_main_queue, (), ^{...});`
//which is the main thread to listen for read notifications,
//testing on main thread is counter productive.
@globalActor
actor TestsActor: GlobalActor {
static var shared = TestsActor()
}
@@ -13,4 +13,29 @@ final class Lc0Tests: BaseEngineTests {
super.setUp()
}
override func testEngineRestart() async {
XCTAssert(!Thread.isMainThread, "Test must be run on a background thread")
XCTAssertNotNil(self.engine, "Failed to initialize engine")
let expectationStartEngine = self.expectation(
description: "Expect engine \(engine.type.name) to start up."
)
let expectationStopEngine = self.expectation(
description: "Expect engine \(engine.type.name) to stop gracefully."
)
expectationStartEngine.expectedFulfillmentCount = 2
await startEngine(expectation: expectationStartEngine)
await stopEngine(expectation: expectationStopEngine)
//LC0 has an internal mutex failure "Unhandled exception: mutex lock failed: Invalid argument"
//when trying to stop and start the engine too fast.
//Adding this 100 ms delay circumvent that issue.
//Once this issue is resolved, this override func
//can be removed and use the EngineRestart test on BeseEngineTests
try? await Task.sleep(for: .milliseconds(100))
await startEngine(expectation: expectationStartEngine)
await fulfillment(of: [expectationStartEngine, expectationStopEngine], timeout: 5)
}
}