diff --git a/CHANGELOG.md b/CHANGELOG.md index e4bbfe6e..f2913bcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,23 @@ # Change Log -## [3.1.0] - 2026-06-04 +## [4.0.0] - 2026-06-26 ### Added - Coded Departure Routes are now parsed from the TXT distribution (`CDR.txt`) in addition to CSV; the six fields present only in the CSV file are `nil` when parsed from TXT - The CSV `TerminalCommFacility` now carries radar, military-operations, and class-airspace data, folded in from the `RDR`, `MIL_OPS`, and `CLS_ARSP` files that the FAA split out of the legacy `TWR` subscriber file — matching the TXT representation +### Changed + +- **BREAKING:** `JSONZipEncoder` and `JSONZipDecoder` are now `Sendable` value types (`struct`) instead of `JSONEncoder`/`JSONDecoder` subclasses, eliminating their unsound `@unchecked Sendable` conformances. Set formatting via `JSONZipEncoder(outputFormatting:)` (the `outputFormatting` property remains available); both types can now be shared safely across concurrency domains +- Progress reporting is now updated synchronously and in order. The downloader's GCD `DispatchQueue.main.async` hop and the per-chunk `Task { @MainActor in … }` hops in the archive and directory distributions were replaced with direct, thread-safe `Progress` mutation, removing a hazard where progress could be reported out of order or after a stream finished +- Adopted the Approachable Concurrency upcoming-feature flags (`NonisolatedNonsendingByDefault`, `InferIsolatedConformances`), which changes the execution semantics of `nonisolated` async work +- **BREAKING:** Raised the minimum deployment targets to macOS 15, iOS 18, tvOS 18, watchOS 11, and visionOS 2 to adopt the standard-library `Synchronization` module (`Mutex`/`Atomic`) + +### Internal + +- Replaced `nonisolated(unsafe)` statics in the test URL-protocol mock with a `Synchronization.Mutex`, and modernized the manual smoke-test harness to top-level `await` + ## [3.0.0] - 2026-06-03 ### Breaking Changes diff --git a/Package.swift b/Package.swift index 83442232..be286f57 100644 --- a/Package.swift +++ b/Package.swift @@ -2,10 +2,15 @@ import PackageDescription +let approachableConcurrency: [SwiftSetting] = [ + .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + .enableUpcomingFeature("InferIsolatedConformances") +] + let package = Package( name: "SwiftNASR", defaultLocalization: "en", - platforms: [.macOS(.v13), .iOS(.v16), .tvOS(.v16), .watchOS(.v9), .visionOS(.v1)], + platforms: [.macOS(.v15), .iOS(.v18), .tvOS(.v18), .watchOS(.v11), .visionOS(.v2)], products: [ .library( @@ -27,6 +32,7 @@ let package = Package( name: "SwiftNASR", dependencies: ["ZIPFoundation", "StreamingCSV"], resources: [.process("Resources")], + swiftSettings: approachableConcurrency, linkerSettings: [.linkedLibrary("swift_Concurrency")] ), .testTarget( @@ -36,6 +42,7 @@ let package = Package( .copy("Resources/MockDistribution"), .copy("Resources/FailingMockDistribution") ], + swiftSettings: approachableConcurrency, linkerSettings: [.linkedLibrary("swift_Concurrency")] ), .executableTarget( @@ -45,6 +52,7 @@ let package = Package( .product(name: "ArgumentParser", package: "swift-argument-parser") ], path: "Tests/SwiftNASR_E2E", + swiftSettings: approachableConcurrency, linkerSettings: [.linkedLibrary("swift_Concurrency")] ) ], diff --git a/Sources/SwiftNASR/Distribution/ArchiveDataDistribution.swift b/Sources/SwiftNASR/Distribution/ArchiveDataDistribution.swift index 6e8b5dc1..6fc0324f 100644 --- a/Sources/SwiftNASR/Distribution/ArchiveDataDistribution.swift +++ b/Sources/SwiftNASR/Distribution/ArchiveDataDistribution.swift @@ -59,7 +59,7 @@ public final class ArchiveDataDistribution: Distribution { _ = try archive.extract(entry, bufferSize: chunkSize, skipCRC32: false, progress: nil) { data in buffer.append(data) - Task { @MainActor in progress.completedUnitCount += Int64(data.count) } + progress.completedUnitCount += Int64(data.count) // Handle both \r\n and \n line endings while true { let crlfRange = buffer.range(of: crlfDelimiter) @@ -133,7 +133,7 @@ public final class ArchiveDataDistribution: Distribution { _ = try archive.extract(entry, bufferSize: chunkSize, skipCRC32: false, progress: nil) { data in - Task { @MainActor in progress.completedUnitCount += Int64(data.count) } + progress.completedUnitCount += Int64(data.count) // Force a copy to avoid ZIPFoundation buffer reuse issues continuation.yield(Data(data)) } diff --git a/Sources/SwiftNASR/Distribution/ArchiveFileDistribution.swift b/Sources/SwiftNASR/Distribution/ArchiveFileDistribution.swift index 943c443b..b5ebb718 100644 --- a/Sources/SwiftNASR/Distribution/ArchiveFileDistribution.swift +++ b/Sources/SwiftNASR/Distribution/ArchiveFileDistribution.swift @@ -64,7 +64,7 @@ public final class ArchiveFileDistribution: Distribution { totalBytesProcessed += UInt64(data.count) buffer.append(data) - Task { @MainActor in progress.completedUnitCount += Int64(data.count) } + progress.completedUnitCount += Int64(data.count) // Process lines from buffer - handle both \r\n and \n line endings while true { @@ -154,7 +154,7 @@ public final class ArchiveFileDistribution: Distribution { _ = try archive.extract(entry, bufferSize: chunkSize, skipCRC32: true, progress: nil) { data in - Task { @MainActor in progress.completedUnitCount += Int64(data.count) } + progress.completedUnitCount += Int64(data.count) // Force a copy to avoid ZIPFoundation buffer reuse issues continuation.yield(Data(data)) } diff --git a/Sources/SwiftNASR/Distribution/DirectoryDistribution.swift b/Sources/SwiftNASR/Distribution/DirectoryDistribution.swift index 76bb67d8..de6757b2 100644 --- a/Sources/SwiftNASR/Distribution/DirectoryDistribution.swift +++ b/Sources/SwiftNASR/Distribution/DirectoryDistribution.swift @@ -75,7 +75,7 @@ public final class DirectoryDistribution: Distribution { if subdata.last == carriageReturn { subdata.removeLast() } - Task { @MainActor in progress.completedUnitCount += Int64(byteCount) } + progress.completedUnitCount += Int64(byteCount) eachLine(subdata) lines += 1 @@ -83,7 +83,7 @@ public final class DirectoryDistribution: Distribution { buffer.removeSubrange(subrange) } else { let data = handle.readData(ofLength: chunkSize) - Task { @MainActor in progress.completedUnitCount += Int64(data.count) } + progress.completedUnitCount += Int64(data.count) guard !data.isEmpty else { if !buffer.isEmpty { // Strip trailing \r for Windows-style line endings @@ -145,7 +145,7 @@ public final class DirectoryDistribution: Distribution { while true { let data = handle.readData(ofLength: chunkSize) guard !data.isEmpty else { break } - Task { @MainActor in progress.completedUnitCount += Int64(data.count) } + progress.completedUnitCount += Int64(data.count) continuation.yield(data) } diff --git a/Sources/SwiftNASR/Documentation.docc/Getting Started.md b/Sources/SwiftNASR/Documentation.docc/Getting Started.md index dd26bc10..203efdd9 100644 --- a/Sources/SwiftNASR/Documentation.docc/Getting Started.md +++ b/Sources/SwiftNASR/Documentation.docc/Getting Started.md @@ -71,7 +71,7 @@ print(sanCarlos.runways[0].length) To avoid parsing a large dataset each time your application loads, I recommend encoding the ``NASRData`` object. Choose the encoder you wish to use; for -example, `JSONEncoder` uses a straightforward and portable format. This class +example, `JSONEncoder` uses a straightforward and portable format. SwiftNASR also provides ``JSONZipEncoder`` to cut down on space when needed. You can encode the whole object, containing all the data you've loaded: diff --git a/Sources/SwiftNASR/Downloaders/Downloader.swift b/Sources/SwiftNASR/Downloaders/Downloader.swift index 8723a1cd..ae394e90 100644 --- a/Sources/SwiftNASR/Downloaders/Downloader.swift +++ b/Sources/SwiftNASR/Downloaders/Downloader.swift @@ -56,10 +56,8 @@ final class DownloadDelegate: NSObject, URLSessionDownloadDelegate, Sendable { totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64 ) { - DispatchQueue.main.async { [weak self] in - self?.progress.completedUnitCount = totalBytesWritten - self?.progress.totalUnitCount = totalBytesExpectedToWrite - } + progress.completedUnitCount = totalBytesWritten + progress.totalUnitCount = totalBytesExpectedToWrite } } diff --git a/Sources/SwiftNASR/Support/JSONZipCoder.swift b/Sources/SwiftNASR/Support/JSONZipCoder.swift index 3b35b20f..1faa5f36 100644 --- a/Sources/SwiftNASR/Support/JSONZipCoder.swift +++ b/Sources/SwiftNASR/Support/JSONZipCoder.swift @@ -1,13 +1,47 @@ import Foundation import ZIPFoundation -public class JSONZipEncoder: JSONEncoder, @unchecked Sendable { - override public func encode(_ value: T) throws -> Data where T: Encodable { +/** + A JSON encoder that compresses its output into a ZIP archive. + + ``JSONZipEncoder`` behaves like `JSONEncoder`, but writes the encoded JSON into + a ZIP archive (as a single `distribution.json` entry) to reduce the size of + serialized ``NASRData``. Decode the result with ``JSONZipDecoder``. + */ +public struct JSONZipEncoder: Sendable { + + /// The output formatting applied to the encoded JSON, mirroring + /// `JSONEncoder/outputFormatting`. + public var outputFormatting: JSONEncoder.OutputFormatting + + /** + Creates a new encoder. + + - Parameter outputFormatting: The output formatting applied to the encoded + JSON. + */ + + public init(outputFormatting: JSONEncoder.OutputFormatting = []) { + self.outputFormatting = outputFormatting + } + + /** + Encodes a value as ZIP-compressed JSON. + + - Parameter value: The value to encode. + - Returns: The compressed archive data. + - Throws: ``JSONZipError`` if the archive could not be created, or an encoding + error from the underlying `JSONEncoder`. + */ + + public func encode(_ value: some Encodable) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = outputFormatting do { - let data = try super.encode(value) + let data = try encoder.encode(value) let archive = try Archive(accessMode: .create) _ = try archive.addEntry( - with: "distribution.json", + with: distributionEntryName, type: .file, uncompressedSize: Int64(data.count) ) { (position: Int64, size: Int) -> Data in @@ -23,24 +57,44 @@ public class JSONZipEncoder: JSONEncoder, @unchecked Sendable { } } -public class JSONZipDecoder: JSONDecoder, @unchecked Sendable { - override public func decode(_ type: T.Type, from data: Data) throws -> T where T: Decodable { +/** + A JSON decoder that reads ZIP-compressed JSON produced by ``JSONZipEncoder``. + */ +public struct JSONZipDecoder: Sendable { + + /// Creates a new decoder. + public init() {} + + /** + Decodes a value from ZIP-compressed JSON. + + - Parameter type: The type to decode. + - Parameter data: The compressed archive data. + - Returns: The decoded value. + - Throws: ``JSONZipError`` if the archive could not be read, or a decoding + error from the underlying `JSONDecoder`. + */ + + public func decode(_ type: T.Type, from data: Data) throws -> T { do { let archive = try Archive(data: data, accessMode: .read, pathEncoding: .ascii) - guard let entry = archive["distribution.json"] else { + guard let entry = archive[distributionEntryName] else { throw JSONZipError.noDistributionFile } var json = Data(capacity: Int(entry.uncompressedSize)) _ = try archive.extract(entry) { json.append($0) } - return try super.decode(type, from: json) + return try JSONDecoder().decode(type, from: json) } catch _ as Archive.ArchiveError { throw JSONZipError.couldntReadArchive } } } +// The name of the single archive entry that holds the encoded JSON. +private let distributionEntryName = "distribution.json" + enum JSONZipError: Swift.Error { case couldntReadArchive case emptyArchive diff --git a/Tests/SwiftNASRTests/Support/JSONZipCoderSpec.swift b/Tests/SwiftNASRTests/Support/JSONZipCoderSpec.swift index c04f7344..acc57f0c 100644 --- a/Tests/SwiftNASRTests/Support/JSONZipCoderSpec.swift +++ b/Tests/SwiftNASRTests/Support/JSONZipCoderSpec.swift @@ -8,11 +8,7 @@ import ZIPFoundation final class JSONZipCoderSpec: QuickSpec { override static func spec() { let object = ["foo": 1, "bar": 2] - var encoder: JSONZipEncoder { - let coder = JSONZipEncoder() - coder.outputFormatting = .sortedKeys - return coder - } + let encoder = JSONZipEncoder(outputFormatting: .sortedKeys) let decoder = JSONZipDecoder() describe("JSONZipEncoder") { diff --git a/Tests/SwiftNASRTests/Support/Mocks/MockURLProtocol.swift b/Tests/SwiftNASRTests/Support/Mocks/MockURLProtocol.swift index 6882094d..a9ff221e 100644 --- a/Tests/SwiftNASRTests/Support/Mocks/MockURLProtocol.swift +++ b/Tests/SwiftNASRTests/Support/Mocks/MockURLProtocol.swift @@ -1,4 +1,5 @@ import Foundation +import Synchronization struct MockResponse { var data: Data? @@ -7,9 +8,21 @@ struct MockResponse { } class MockURLProtocol: URLProtocol { - // Quick tests do not run in parallel so access should always be synchronous - nonisolated(unsafe) static var nextResponse: MockResponse? - nonisolated(unsafe) static var lastURL: URL? + // `URLProtocol`'s `init`/`startLoading` are synchronous callbacks driven by + // `URLSession`, so an actor doesn't fit; a Mutex provides real mutual exclusion + // for this shared test fixture without requiring the protected state to be + // statically `Sendable`. + private static let state = Mutex(State()) + + static var nextResponse: MockResponse? { + get { state.withLock { $0.nextResponse } } + set { state.withLock { $0.nextResponse = newValue } } + } + + static var lastURL: URL? { + get { state.withLock { $0.lastURL } } + set { state.withLock { $0.lastURL = newValue } } + } override init( request: URLRequest, @@ -54,4 +67,9 @@ class MockURLProtocol: URLProtocol { override func stopLoading() { // Required, but not used in this mock } + + private struct State { + var nextResponse: MockResponse? + var lastURL: URL? + } } diff --git a/Tests/SwiftNASR_Simple/main.swift b/Tests/SwiftNASR_Simple/main.swift index 01616ea6..a5493516 100644 --- a/Tests/SwiftNASR_Simple/main.swift +++ b/Tests/SwiftNASR_Simple/main.swift @@ -20,19 +20,14 @@ if FileManager.default.fileExists(atPath: txtPath.path) { print("NASR created!") print("About to call nasr.load()...") - Task { - do { - try await nasr.load { progress in - print("Load progress: \(progress.fractionCompleted)") - } - print("Load completed successfully!") - } catch { - print("Load failed: \(error)") + do { + try await nasr.load { progress in + print("Load progress: \(progress.fractionCompleted)") } - exit(0) + print("Load completed successfully!") + } catch { + print("Load failed: \(error)") } - - RunLoop.main.run() } else { print("TXT archive not found") }