From 33ef0d77e144eaab0734b3da02c3fed6629166eb Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Thu, 11 Jun 2026 08:57:10 -0600 Subject: [PATCH] Support WebAssembly + fix Linux collection-cast crash - Guard @Published / ObservableObject with #if canImport(Combine) instead of #if !os(Linux) && !os(Windows). The latter includes os(WASI), so wasm took the Combine path and failed to build (unknown attribute 'Published'). canImport(Combine) correctly excludes Linux, Windows, AND wasm. (AppState#149) - Dictionary.get(_:as:): box through Any before the conditional cast ((self[key] as Any) as? Item). Casting Optional -> [ConcreteType] takes the runtime _arrayForceCast path, which aborts (swift_dynamicCastFailure) on Linux/WASI for collection-typed values. (AppState#151) - Add array round-trip regression tests through Cache. 178 tests pass on macOS. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/Cache/Cache/Cache.swift | 11 ++++--- .../Dictionary/Dictionary+Cacheable.swift | 8 ++++- Tests/CacheTests/CacheTests.swift | 32 +++++++++++++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/Sources/Cache/Cache/Cache.swift b/Sources/Cache/Cache/Cache.swift index 2772260..352d121 100644 --- a/Sources/Cache/Cache/Cache.swift +++ b/Sources/Cache/Cache/Cache.swift @@ -19,11 +19,14 @@ open class Cache: Cacheable, @unchecked Sendable { /// Using NSRecursiveLock to prevent re-entrant lock deadlocks with @Published property wrapper fileprivate var lock: NSRecursiveLock - #if os(Linux) || os(Windows) - fileprivate var cache: [Key: Value] = [:] - #else + #if canImport(Combine) /// The actual cache dictionary of key-value pairs. @Published fileprivate var cache: [Key: Value] = [:] + #else + // Combine (and therefore `@Published`) is unavailable on Linux, Windows, and + // WebAssembly (`os(WASI)`). Guarding on `canImport(Combine)` — rather than + // `!os(Linux) && !os(Windows)` — correctly excludes wasm too. + fileprivate var cache: [Key: Value] = [:] #endif /** @@ -160,7 +163,7 @@ open class Cache: Cacheable, @unchecked Sendable { } } -#if !os(Linux) && !os(Windows) +#if canImport(Combine) extension Cache: ObservableObject { } #endif diff --git a/Sources/Cache/Dictionary/Dictionary+Cacheable.swift b/Sources/Cache/Dictionary/Dictionary+Cacheable.swift index a6c7416..3505287 100644 --- a/Sources/Cache/Dictionary/Dictionary+Cacheable.swift +++ b/Sources/Cache/Dictionary/Dictionary+Cacheable.swift @@ -15,7 +15,13 @@ extension Dictionary: Cacheable { - Returns: The casted value for the given key, or `nil` if the key doesn't exist or the cast fails. */ public func get(_ key: Key, as: Item.Type = Item.self) -> Item? { - guard let value = self[key] as? Item else { + // Route through an explicit `Any` boxing step before the conditional cast. This avoids + // `swift_dynamicCastFailure` / `_arrayForceCast` aborts on Linux and WASI when casting an + // `Optional` that wraps a collection type (e.g. `[SomeStruct]`): without the + // intermediate cast, the runtime takes the `Optional` → `[T]` forced-array-cast path, + // which is not implemented the same way off Darwin. Boxing to `Any` first normalises the + // dynamic type to the concrete `Item` path. + guard let value = (self[key] as Any) as? Item else { return nil } diff --git a/Tests/CacheTests/CacheTests.swift b/Tests/CacheTests/CacheTests.swift index 0aa73ea..6a82b92 100644 --- a/Tests/CacheTests/CacheTests.swift +++ b/Tests/CacheTests/CacheTests.swift @@ -287,4 +287,36 @@ final class CacheTests: XCTestCase { XCTAssertEqual(cache[.text, default: "missing value"], "missing value") } + + // MARK: - Issue #151 — collection-typed values through an `Any` cache + + private struct Point: Equatable { + let x: Int + let y: Int + } + + func testArrayOfStructsRoundTripThroughAnyCache() { + let cache: Cache = Cache() + let points = [Point(x: 1, y: 2), Point(x: 3, y: 4)] + cache.set(value: points, forKey: "points") + + let resolved: [Point]? = cache.get("points", as: [Point].self) + XCTAssertEqual(resolved, points) + } + + func testArrayOfStringsRoundTripThroughAnyCache() { + let cache: Cache = Cache() + cache.set(value: ["a", "b", "c"], forKey: "letters") + + XCTAssertEqual(cache.get("letters", as: [String].self), ["a", "b", "c"]) + } + + func testArrayResolveRoundTripThroughAnyCache() throws { + let cache: Cache = Cache() + let values = [10, 20, 30] + cache.set(value: values, forKey: "values") + + let resolved: [Int] = try cache.resolve("values", as: [Int].self) + XCTAssertEqual(resolved, values) + } }