Skip to content
Merged
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
8 changes: 7 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@

import PackageDescription

let approachableConcurrency: [SwiftSetting] = [
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("InferIsolatedConformances")
]

let package = Package(
name: "GameGenerator",
defaultLocalization: "en",
Expand All @@ -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]
Expand Down
50 changes: 39 additions & 11 deletions Sources/GameGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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<Void> {
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()
Expand All @@ -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()
}
}
27 changes: 18 additions & 9 deletions Sources/lib/GameFinder.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import Algorithms
import Dispatch
import Foundation

/**
Expand Down Expand Up @@ -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.
///
Expand All @@ -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
Expand All @@ -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`.
Expand All @@ -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)
Expand All @@ -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 }
Expand Down
Loading