Skip to content
Open
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
11 changes: 7 additions & 4 deletions Sources/Cache/Cache/Cache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,14 @@ open class Cache<Key: Hashable, Value>: 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

/**
Expand Down Expand Up @@ -160,7 +163,7 @@ open class Cache<Key: Hashable, Value>: Cacheable, @unchecked Sendable {
}
}

#if !os(Linux) && !os(Windows)
#if canImport(Combine)
extension Cache: ObservableObject { }
#endif
Comment on lines +166 to 168

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure that @Published and ObservableObject compile correctly across all platforms and build configurations (especially when this file is compiled incrementally or in isolation), we should explicitly import Combine when it is available.

Suggested change
#if canImport(Combine)
extension Cache: ObservableObject { }
#endif
#if canImport(Combine)
import Combine
extension Cache: ObservableObject { }
#endif


Expand Down
8 changes: 7 additions & 1 deletion Sources/Cache/Dictionary/Dictionary+Cacheable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item>(_ 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<Any>` that wraps a collection type (e.g. `[SomeStruct]`): without the
// intermediate cast, the runtime takes the `Optional<Any>` → `[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
}
Comment on lines +18 to 26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Instead of boxing the optional value into Any via (self[key] as Any) as? Item, we can simply unwrap the optional value first using guard let rawValue = self[key]. This completely avoids the buggy Optional<Any> to [T] cast path on Linux/WASI, is much more idiomatic, and avoids potential edge cases where nil is boxed into Any (which could behave unexpectedly if Item is Any or an optional type).

Suggested change
// 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<Any>` that wraps a collection type (e.g. `[SomeStruct]`): without the
// intermediate cast, the runtime takes the `Optional<Any>` → `[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
}
// Unwrapping the optional value first avoids a Swift runtime bug on Linux and WASI
// where casting `Optional<Any>` directly to a collection type (e.g. `[T]`) aborts
// with `swift_dynamicCastFailure`.
guard let rawValue = self[key], let value = rawValue as? Item else {
return nil
}


Expand Down
32 changes: 32 additions & 0 deletions Tests/CacheTests/CacheTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Any> = 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<String, Any> = 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<String, Any> = 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)
}
}
Loading