Repository: pointfreeco/swift-case-paths (swift)

Issue:
Sendable Case Paths?
I've been getting some warnings when I capture a 'non-sendable' case path in a `@Sendable` closure.

It there any reason `CasePath` can't be marked `Sendable` where the `Root` is already `Sendable`? I _think_ this will be okay, but would like to make sure

🤔

Pull request description:
We can make `CasePath` sendable by locking around the part where we memoize some data from the runtime metadata. There's a chance we missed other parts that need locking, so feel free to let us know if you spot something!

Fixes #89

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 18e2a09f..7c301022 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -8,6 +8,10 @@ on:
     branches:
       - '*'
 
+concurrency:
+  group: ci-${{ github.ref }}
+  cancel-in-progress: true
+
 jobs:
   macos-11:
     name: MacOS 11
diff --git a/Makefile b/Makefile
index 1a8bf2c7..4f5797e3 100644
--- a/Makefile
+++ b/Makefile
@@ -10,7 +10,11 @@ test-linux:
 
 test-swift:
 	swift test \
-		--enable-test-discovery \
+ 		--enable-test-discovery \
+		--parallel
+	swift test \
+	  -c release \
+ 		--enable-test-discovery \
 		--parallel
 
 format:
diff --git a/Sources/CasePaths/CasePath.swift b/Sources/CasePaths/CasePath.swift
index 29d2ec99..534e8ce4 100644
--- a/Sources/CasePaths/CasePath.swift
+++ b/Sources/CasePaths/CasePath.swift
@@ -1,3 +1,5 @@
+import Foundation
+
 /// A path that supports embedding a value in a root and attempting to extract a root's embedded
 /// value.
 ///
@@ -11,9 +13,20 @@ public struct CasePath<Root, Value> {
   /// - Parameters:
   ///   - embed: A function that always succeeds in embedding a value in a root.
   ///   - extract: A function that can optionally fail in extracting a value from a root.
-  public init(embed: @escaping (Value) -> Root, extract: @escaping (Root) -> Value?) {
-    self._embed = embed
-    self._extract = extract
+  public init(
+    embed: @escaping (Value) -> Root,
+    extract: @escaping (Root) -> Value?
+  ) {
+    self._embed = {
+      lock.lock()
+      defer { lock.unlock() }
+      return embed($0)
+    }
+    self._extract = {
+      lock.lock()
+      defer { lock.unlock() }
+      return extract($0)
+    }
   }
 
   /// Returns a root by embedding a value.
@@ -65,6 +78,10 @@ public struct CasePath<Root, Value> {
   }
 }
 
+#if canImport(_Concurrency) && compiler(>=5.5.2)
+  extension CasePath: @unchecked Sendable {}
+#endif
+
 extension CasePath: CustomStringConvertible {
   public var description: String {
     "CasePath<\(Root.self), \(Value.self)>"
@@ -72,3 +89,5 @@ extension CasePath: CustomStringConvertible {
 }
 
 struct ExtractionFailed: Error {}
+
+private let lock = NSRecursiveLock()


Test patch:
diff --git a/Tests/CasePathsTests/CasePathsTests.swift b/Tests/CasePathsTests/CasePathsTests.swift
index 09db8f36..f4d39e32 100644
--- a/Tests/CasePathsTests/CasePathsTests.swift
+++ b/Tests/CasePathsTests/CasePathsTests.swift
@@ -1,16 +1,8 @@
 import CasePaths
 import XCTest
 
-// Replace this with XCTUnwrap when we drop support for Xcode 11.3.
-private func unwrap<Wrapped>(_ optional: Wrapped?) throws -> Wrapped {
-  guard let wrapped = optional else { throw UnexpectedNil() }
-  return wrapped
-}
-private struct UnexpectedNil: Error {}
-
 protocol TestProtocol {}
 extension Int: TestProtocol {}
-
 protocol TestClassProtocol: AnyObject {}
 
 final class CasePathsTests: XCTestCase {
@@ -1170,8 +1162,60 @@ final class CasePathsTests: XCTestCase {
     try (/Foo.bar).modify(&foo) { $0 *= 2 }
     XCTAssertEqual(foo, .bar(84))
   }
+
+  #if canImport(_Concurrency) && compiler(>=5.5.2)
+    func testConcurrency_SharedCasePath() async throws {
+      enum Enum { case payload(Int) }
+      let casePath = /Enum.payload
+
+      await withTaskGroup(of: Void.self) { group in
+        for index in 1...100_000 {
+          group.addTask {
+            XCTAssertEqual(casePath.extract(from: Enum.payload(index)), index)
+          }
+        }
+      }
+    }
+
+    func testConcurrency_NonSendableEmbed() async throws {
+      enum Enum: Equatable { case payload(Int) }
+      let iterationCount = 100_000
+      var count = 0
+
+      await withTaskGroup(of: Void.self) { group in
+        for index in 1...iterationCount {
+          let casePath1 = CasePath<Enum, Int> {
+            count += 1
+            return .payload($0)
+          }
+          group.addTask {
+            XCTAssertEqual(casePath1.extract(from: Enum.payload(index)), index)
+            XCTAssertEqual(casePath1.embed(index), .payload(index))
+          }
+
+          let casePath2 = CasePath<Enum, Int> {
+            count += 1
+            return .payload($0)
+          }
+          group.addTask {
+            XCTAssertEqual(casePath2.extract(from: Enum.payload(index)), index)
+            XCTAssertEqual(casePath2.embed(index), .payload(index))
+          }
+        }
+      }
+
+      XCTAssertEqual(count, iterationCount * 4)
+    }
+  #endif
 }
 
 private class TestObject: Equatable {
   static func == (lhs: TestObject, rhs: TestObject) -> Bool { lhs === rhs }
 }
+
+// Replace this with XCTUnwrap when we drop support for Xcode 11.3.
+private func unwrap<Wrapped>(_ optional: Wrapped?) throws -> Wrapped {
+  guard let wrapped = optional else { throw UnexpectedNil() }
+  return wrapped
+}
+private struct UnexpectedNil: Error {}