diff --git a/Package.swift b/Package.swift index d9c2d4e..88f1d9e 100644 --- a/Package.swift +++ b/Package.swift @@ -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", diff --git a/README.md b/README.md index 2daf028..3a29c44 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/ChessKitEngine/Engine.swift b/Sources/ChessKitEngine/Engine.swift index 1f44fa2..f7b11fb 100644 --- a/Sources/ChessKitEngine/Engine.swift +++ b/Sources/ChessKitEngine/Engine.swift @@ -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? { + 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? + + /// A reference to AsyncStream's continuation for later access by `EngineMessenger.responseHandler` + private(set) var streamContinuation: AsyncStream.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.Continuation) -> Void in + Task{ await setStreamContinuation(continuation) } + } + } + + func clearAsyncStream() async { + self.asyncStream = nil + self.streamContinuation = nil + } + + private func setStreamContinuation(_ continuation: AsyncStream.Continuation?) async { + self.streamContinuation = continuation + } +} diff --git a/Sources/ChessKitEngine/EngineCommand/EngineCommand.swift b/Sources/ChessKitEngine/EngineCommand/EngineCommand.swift index 7770ec6..cac922a 100644 --- a/Sources/ChessKitEngine/EngineCommand/EngineCommand.swift +++ b/Sources/ChessKitEngine/EngineCommand/EngineCommand.swift @@ -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 + // → complete + + switch response { + case nil: + return .uci + case .uciok: + return .isready + default: + return nil + } + } } diff --git a/Sources/ChessKitEngine/EngineCommand/EngineCommandPosition.swift b/Sources/ChessKitEngine/EngineCommand/EngineCommandPosition.swift index c832228..8de51c9 100644 --- a/Sources/ChessKitEngine/EngineCommand/EngineCommandPosition.swift +++ b/Sources/ChessKitEngine/EngineCommand/EngineCommandPosition.swift @@ -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. diff --git a/Sources/ChessKitEngine/EngineResponse/EngineResponse.swift b/Sources/ChessKitEngine/EngineResponse/EngineResponse.swift index 4465817..5b3bdfe 100644 --- a/Sources/ChessKitEngine/EngineResponse/EngineResponse.swift +++ b/Sources/ChessKitEngine/EngineResponse/EngineResponse.swift @@ -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 "`, `"id author "` /// /// See [UCI protocol documentation](https://backscattering.de/chess/uci/2006-04.txt) diff --git a/Sources/ChessKitEngine/EngineResponse/EngineResponseID.swift b/Sources/ChessKitEngine/EngineResponse/EngineResponseID.swift index 9ec87e3..eb08cf5 100644 --- a/Sources/ChessKitEngine/EngineResponse/EngineResponseID.swift +++ b/Sources/ChessKitEngine/EngineResponse/EngineResponseID.swift @@ -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). diff --git a/Sources/ChessKitEngine/EngineResponse/EngineResponseInfo.swift b/Sources/ChessKitEngine/EngineResponse/EngineResponseInfo.swift index ad3a6e5..dce8ef2 100644 --- a/Sources/ChessKitEngine/EngineResponse/EngineResponseInfo.swift +++ b/Sources/ChessKitEngine/EngineResponse/EngineResponseInfo.swift @@ -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] } diff --git a/Sources/ChessKitEngine/EngineSetupLoop.swift b/Sources/ChessKitEngine/EngineSetupLoop.swift deleted file mode 100644 index 3cc6d7a..0000000 --- a/Sources/ChessKitEngine/EngineSetupLoop.swift +++ /dev/null @@ -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 - // → complete - - switch response { - case nil: - return .uci - case .uciok: - return .isready - case .readyok: - startupDidComplete() - return nil - default: - return nil - } - } - -} diff --git a/Sources/ChessKitEngine/EngineType.swift b/Sources/ChessKitEngine/EngineType.swift index dbf7862..bd38df0 100644 --- a/Sources/ChessKitEngine/EngineType.swift +++ b/Sources/ChessKitEngine/EngineType.swift @@ -6,7 +6,7 @@ import ChessKitEngineCore /// Possible engines available in `ChessKitEngine`. -public enum EngineType: Int { +public enum EngineType: Int, Sendable { case stockfish case lc0 diff --git a/Sources/ChessKitEngineCore/EngineMessenger/EngineMessenger.h b/Sources/ChessKitEngineCore/EngineMessenger/EngineMessenger.h index ba09039..dadaeb9 100644 --- a/Sources/ChessKitEngineCore/EngineMessenger/EngineMessenger.h +++ b/Sources/ChessKitEngineCore/EngineMessenger/EngineMessenger.h @@ -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. diff --git a/Sources/ChessKitEngineCore/EngineMessenger/EngineMessenger.mm b/Sources/ChessKitEngineCore/EngineMessenger/EngineMessenger.mm index 7f378bb..70b645b 100644 --- a/Sources/ChessKitEngineCore/EngineMessenger/EngineMessenger.mm +++ b/Sources/ChessKitEngineCore/EngineMessenger/EngineMessenger.mm @@ -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 { diff --git a/Tests/ChessKitEngineTests/EngineTests/BaseEngineTests.swift b/Tests/ChessKitEngineTests/EngineTests/BaseEngineTests.swift index f0524ef..86a348a 100644 --- a/Tests/ChessKitEngineTests/EngineTests/BaseEngineTests.swift +++ b/Tests/ChessKitEngineTests/EngineTests/BaseEngineTests.swift @@ -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() } diff --git a/Tests/ChessKitEngineTests/EngineTests/Lc0Tests.swift b/Tests/ChessKitEngineTests/EngineTests/Lc0Tests.swift index 1de8b35..46522e3 100644 --- a/Tests/ChessKitEngineTests/EngineTests/Lc0Tests.swift +++ b/Tests/ChessKitEngineTests/EngineTests/Lc0Tests.swift @@ -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) + } }