diff --git a/Package.swift b/Package.swift index 13c60e6..ce999ba 100644 --- a/Package.swift +++ b/Package.swift @@ -3,6 +3,11 @@ import PackageDescription +let approachableConcurrency: [SwiftSetting] = [ + .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + .enableUpcomingFeature("InferIsolatedConformances") +] + let package = Package( name: "GameGenerator", defaultLocalization: "en", @@ -21,7 +26,8 @@ let package = Package( .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Algorithms", package: "swift-algorithms"), .product(name: "Progress", package: "Progress.swift") - ] + ], + swiftSettings: approachableConcurrency ) ], swiftLanguageModes: [.v6] diff --git a/Sources/GameGenerator.swift b/Sources/GameGenerator.swift index 8bc9473..0923d87 100644 --- a/Sources/GameGenerator.swift +++ b/Sources/GameGenerator.swift @@ -25,6 +25,8 @@ struct GameGenerator: AsyncParsableCommand { version: "1.0.0" ) + private static let jsonArrayTerminator = Data("]".utf8) + @Option( name: .shortAndLong, help: "The text file containing dictionary words.", @@ -40,6 +42,21 @@ struct GameGenerator: AsyncParsableCommand { ) var output: URL? + // Bridges SIGINT into an async stream that emits once per interrupt. There is + // no native Swift-concurrency signal API, so a DispatchSource signal source + // remains the underlying primitive; the default terminate-on-SIGINT behavior + // is suppressed so the search can shut down gracefully and leave well-formed + // JSON behind. + private static func interruptSignals() -> AsyncStream { + AsyncStream { continuation in + let source = DispatchSource.makeSignalSource(signal: SIGINT) + signal(SIGINT, SIG_IGN) + source.setEventHandler { continuation.yield() } + continuation.onTermination = { _ in source.cancel() } + source.resume() + } + } + /// Entry point for the command line tool. mutating func run() async throws { let words = Words() @@ -48,18 +65,29 @@ struct GameGenerator: AsyncParsableCommand { if let output { FileManager.default.createFile(atPath: output.path(), contents: nil) } let outputStream = output == nil ? FileHandle.standardOutput : try FileHandle(forWritingTo: output!) - let signalSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main) - signal(SIGINT, SIG_IGN) // Ignore default SIGINT behavior - signalSource.setEventHandler { - // Flush the buffer when the script is aborted - try? outputStream.synchronize() - try? outputStream.write(contentsOf: "]".data(using: .ascii)!) - try? outputStream.close() - Self.exit() - } - signalSource.resume() let gameFinder = GameFinder(words: words, streamTo: outputStream) - try await gameFinder.findGames(showProgress: output != nil) + try await search(with: gameFinder, showingProgress: output != nil) + finalize(outputStream) + } + + // Runs the search alongside an interrupt watcher; the first to finish cancels + // the other. Cancelling the search lets in-flight games finish streaming + // before this returns, so the output is complete before it is closed. + private func search(with gameFinder: GameFinder, showingProgress showProgress: Bool) async throws + { + let interrupts = Self.interruptSignals() + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { try await gameFinder.findGames(showProgress: showProgress) } + group.addTask { for await _ in interrupts { return } } + + for try await _ in group { group.cancelAll() } + } + } + + private func finalize(_ stream: FileHandle) { + try? stream.synchronize() + try? stream.write(contentsOf: Self.jsonArrayTerminator) + try? stream.close() } } diff --git a/Sources/lib/GameFinder.swift b/Sources/lib/GameFinder.swift index d093b3d..f67147e 100644 --- a/Sources/lib/GameFinder.swift +++ b/Sources/lib/GameFinder.swift @@ -1,5 +1,4 @@ import Algorithms -import Dispatch import Foundation /** @@ -63,6 +62,7 @@ actor GameFinder { private let words: Words private let stream: FileHandle + private let encoder: JSONEncoder /// Creates a new GameFinder which streams found games to a file handle. /// @@ -71,6 +71,10 @@ actor GameFinder { init(words: Words, streamTo stream: FileHandle) { self.words = words self.stream = stream + + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + self.encoder = encoder } /// Begins an asynchronous, stochastic process to find possible combinations @@ -84,7 +88,9 @@ actor GameFinder { /// the fourtiles are added back to the stack and the stack is re-shuffled. /// /// This process will most likely never complete, as there will be remaining - /// fourtiles that cannot be arranged and split to produce a valid board. + /// fourtiles that cannot be arranged and split to produce a valid board. The + /// search therefore runs until the surrounding task is cancelled; cancellation + /// stops it once the games already in flight have finished streaming. /// /// - Parameter showProgress: If true, a progress bar is written to `stdout`. /// Set this to false when streaming output to `stdout`. @@ -98,20 +104,16 @@ actor GameFinder { let fourtileCount = await fourtiles.count / Self.numFourtilesPerGame let progress = showProgress ? ProgressActor(count: fourtileCount) : nil - let encoder = JSONEncoder() - encoder.outputFormatting = .prettyPrinted - while await fourtiles.count >= Self.numFourtilesPerGame { + if Task.isCancelled { return } try await withThrowingDiscardingTaskGroup { group in while let fourtilesForGame = await fourtiles.pop(count: Self.numFourtilesPerGame) { + if Task.isCancelled { break } group.addTask { guard fourtilesForGame.count == Self.numFourtilesPerGame else { return } if let game = await self.findGame(forWords: fourtilesForGame) { - try await MainActor.run { - try self.stream.write(contentsOf: encoder.encode(game)) - try self.stream.write(contentsOf: ",\n".data(using: .ascii)!) - } + try await self.append(game) await progress?.next() } else { await fourtiles.push(fourtiles: fourtilesForGame) @@ -122,6 +124,13 @@ actor GameFinder { } } + // Running on the actor serializes the writes so the concurrent task-group + // children cannot interleave their JSON output. + private func append(_ game: Game) throws { + try stream.write(contentsOf: encoder.encode(game)) + try stream.write(contentsOf: ",\n".data(using: .ascii)!) + } + private func findGame(forWords fourtiles: [String]) async -> Game? { let tileSizes = fourtiles.map { Self.tileSizes[$0.count]!.shuffled() } guard let tiles = tilesFor(words: fourtiles, sizes: tileSizes) else { return nil }