mirror of
https://github.com/chesskit-app/chesskit-engine.git
synced 2026-05-19 15:50:35 +00:00
Add support for strict swift concurrency (#21)
This commit is contained in:
+6
-3
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user