From 36a62d8a8d83b0a7d003f62bac357702ee7d5f33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrien=20Coye=20de=20Brune=CC=81lis?= Date: Wed, 29 May 2024 13:58:10 +0200 Subject: [PATCH 1/3] feat: New SendableProperty wrapper to protect RW access of a property. --- .../{ => Sendable}/SendableArray.swift | 2 +- .../{ => Sendable}/SendableDictionary.swift | 0 .../Sendable/SendableProperty.swift | 51 ++++ .../Sendable/UTSendableArray.swift | 176 +++++++++++ .../Sendable/UTSendableDictionary.swift | 147 +++++++++ .../Sendable/UTSendableProperty.swift | 99 ++++++ .../UTCollectionTests.swift | 287 ------------------ 7 files changed, 474 insertions(+), 288 deletions(-) rename Sources/InfomaniakCore/Asynchronous/{ => Sendable}/SendableArray.swift (98%) rename Sources/InfomaniakCore/Asynchronous/{ => Sendable}/SendableDictionary.swift (100%) create mode 100644 Sources/InfomaniakCore/Asynchronous/Sendable/SendableProperty.swift create mode 100644 Tests/InfomaniakCoreTests/Sendable/UTSendableArray.swift create mode 100644 Tests/InfomaniakCoreTests/Sendable/UTSendableDictionary.swift create mode 100644 Tests/InfomaniakCoreTests/Sendable/UTSendableProperty.swift diff --git a/Sources/InfomaniakCore/Asynchronous/SendableArray.swift b/Sources/InfomaniakCore/Asynchronous/Sendable/SendableArray.swift similarity index 98% rename from Sources/InfomaniakCore/Asynchronous/SendableArray.swift rename to Sources/InfomaniakCore/Asynchronous/Sendable/SendableArray.swift index 392b9e0..b84c069 100644 --- a/Sources/InfomaniakCore/Asynchronous/SendableArray.swift +++ b/Sources/InfomaniakCore/Asynchronous/Sendable/SendableArray.swift @@ -29,7 +29,7 @@ public final class SendableArray: @unchecked Sendable, Sequence { /// Internal collection private(set) var content: [T] - public init(content: [T] = Array()) { + public init(content: [T] = [T]()) { self.content = content } diff --git a/Sources/InfomaniakCore/Asynchronous/SendableDictionary.swift b/Sources/InfomaniakCore/Asynchronous/Sendable/SendableDictionary.swift similarity index 100% rename from Sources/InfomaniakCore/Asynchronous/SendableDictionary.swift rename to Sources/InfomaniakCore/Asynchronous/Sendable/SendableDictionary.swift diff --git a/Sources/InfomaniakCore/Asynchronous/Sendable/SendableProperty.swift b/Sources/InfomaniakCore/Asynchronous/Sendable/SendableProperty.swift new file mode 100644 index 0000000..9d44583 --- /dev/null +++ b/Sources/InfomaniakCore/Asynchronous/Sendable/SendableProperty.swift @@ -0,0 +1,51 @@ +/* + Infomaniak Core - iOS + Copyright (C) 2024 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Foundation + +@propertyWrapper +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public final class SendableProperty: @unchecked Sendable { + /// Serial locking queue + let lock = DispatchQueue(label: "com.infomaniak.core.SendableProperty.lock") + + /// Store the resolved service + var property: Property? + + public init() { + // META: Sonar Cloud happy + } + + public var wrappedValue: Property? { + get { + lock.sync { + return self.property + } + } + set { + lock.sync { + self.property = newValue + } + } + } + + /// The property wrapper itself for debugging and testing + public var projectedValue: SendableProperty { + self + } +} diff --git a/Tests/InfomaniakCoreTests/Sendable/UTSendableArray.swift b/Tests/InfomaniakCoreTests/Sendable/UTSendableArray.swift new file mode 100644 index 0000000..3f01740 --- /dev/null +++ b/Tests/InfomaniakCoreTests/Sendable/UTSendableArray.swift @@ -0,0 +1,176 @@ +/* + Infomaniak Core - iOS + Copyright (C) 2024 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import InfomaniakCore +import XCTest + +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +final class UTSendableArray: XCTestCase { + func testInsertSubscript() async { + // GIVEN + let collection = SendableArray() + + // WHEN + let t = Task.detached { + collection[0] = "a" + collection[1] = "b" + collection[2] = "c" + } + + _ = await t.result + + // THEN + XCTAssertEqual(collection.count, 3) + } + + func testInsert() async { + // GIVEN + let collection = SendableArray() + + // WHEN + let t = Task.detached { + collection.append("a") + collection.append("b") + } + + _ = await t.result + + // THEN + XCTAssertEqual(collection.count, 2) + } + + func testUpdate() async { + // GIVEN + let collection = SendableArray() + + // WHEN + let t = Task.detached { + collection.append("a") + collection[0] = "b" + collection[1] = "c" + } + + _ = await t.result + + // THEN + XCTAssertEqual(collection.count, 2) + } + + func testRemoveAll() async { + // GIVEN + let collection = SendableArray() + collection.append("a") + collection.append("b") + + // WHEN + let t = Task.detached { + collection.removeAll() + } + + _ = await t.result + + // THEN + XCTAssertTrue(collection.isEmpty) + } + + func testRemoveAllWhere() async { + // GIVEN + let collection = SendableArray() + collection.append("a") + collection.append("b") + collection.append("c") + + // WHEN + let t = Task.detached { + collection.removeAll(where: { $0 == "b" }) + } + + _ = await t.result + + // THEN + XCTAssertFalse(collection.values.contains("b")) + } + + func testIterator() async { + // GIVEN + let collection = SendableArray() + collection.append("a") + collection.append("b") + + var iterator = collection.makeIterator() + + // WHEN + // We remove all items in the collection + let t = Task.detached { + collection.removeAll() + } + + await t.finish() + + // THEN + XCTAssertTrue(collection.values.isEmpty, "The collection is expected to be empty") + + // We can work with the captured enumeration + var isEmpty = true + while let value = iterator.next() { + isEmpty = false + + if value == "a" || value == "b" { + // OK + } else { + XCTFail("unexpected value:\(value)") + } + } + + XCTAssertFalse(isEmpty, "the iterator is not supposed to be empty") + } + + func testEnumerated() async { + // GIVEN + let collection = SendableArray() + collection.append("a") + collection.append("b") + + let collectionEnumerated = collection.enumerated() + + // WHEN + // We remove all items in the collection + let t = Task.detached { + collection.removeAll() + } + + await t.finish() + + // THEN + XCTAssertTrue(collection.values.isEmpty, "The collection is expected to be empty") + + // We can work with the captured enumeration + var isEmpty = true + for (index, value) in collectionEnumerated { + isEmpty = false + + if value == "a" || value == "b" { + // OK + } else { + XCTFail("unexpected value:\(value) at index:\(index)") + } + } + + XCTAssertFalse(isEmpty, "the iterator is not supposed to be empty") + } +} diff --git a/Tests/InfomaniakCoreTests/Sendable/UTSendableDictionary.swift b/Tests/InfomaniakCoreTests/Sendable/UTSendableDictionary.swift new file mode 100644 index 0000000..0699818 --- /dev/null +++ b/Tests/InfomaniakCoreTests/Sendable/UTSendableDictionary.swift @@ -0,0 +1,147 @@ +/* + Infomaniak Core - iOS + Copyright (C) 2024 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import InfomaniakCore +import XCTest + +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +final class UTSendableDictionary: XCTestCase { + func testInsertSubscript() async { + // GIVEN + let collection = SendableDictionary() + + // WHEN + let t = Task.detached { + collection["a"] = 1 + collection["b"] = 2 + } + + _ = await t.result + + // THEN + XCTAssertEqual(collection.count, 2) + } + + func testInsert() async { + // GIVEN + let collection = SendableDictionary() + + // WHEN + let t = Task.detached { + collection.setValue(1, for: "a") + collection.setValue(2, for: "b") + } + + _ = await t.result + + // THEN + XCTAssertEqual(collection.count, 2) + } + + func testRemoveAll() async { + // GIVEN + let collection = SendableDictionary() + collection.setValue(1, for: "a") + collection.setValue(2, for: "b") + + // WHEN + let t = Task.detached { + collection.removeAll() + } + + _ = await t.result + + // THEN + XCTAssertTrue(collection.values.isEmpty) + } + + func testIterator() async { + // GIVEN + let collection = SendableDictionary() + collection.setValue(1, for: "a") + collection.setValue(2, for: "b") + + var iterator = collection.makeIterator() + + // WHEN + // We remove all items in the collection + let t = Task.detached { + collection.removeAll() + } + + await t.finish() + + // THEN + XCTAssertTrue(collection.values.isEmpty, "The collection is expected to be empty") + + // We can work with the captured enumeration + var isEmpty = true + while let (key, value) = iterator.next() { + isEmpty = false + + if key == "a" { + XCTAssertEqual(value, 1) + } else if key == "b" { + XCTAssertEqual(value, 2) + } else { + XCTFail("unexpected key:\(key) value:\(value)") + } + } + + XCTAssertFalse(isEmpty, "the iterator is not supposed to be empty") + } + + func testEnumerated() async { + // GIVEN + let collection = SendableDictionary() + collection.setValue(1, for: "a") + collection.setValue(2, for: "b") + + let collectionEnumerated = collection.enumerated() + + // WHEN + // We remove all items in the collection + let t = Task.detached { + collection.removeAll() + } + + await t.finish() + + // THEN + XCTAssertTrue(collection.values.isEmpty, "The collection is expected to be empty") + + // We can work with the captured enumeration + var isEmpty = true + for (index, node) in collectionEnumerated { + isEmpty = false + + let key = node.0 + let value = node.1 + + if key == "a" { + XCTAssertEqual(value, 1) + } else if key == "b" { + XCTAssertEqual(value, 2) + } else { + XCTFail("unexpected key:\(key) value:\(value) at index:\(index) ") + } + } + + XCTAssertFalse(isEmpty, "the iterator is not supposed to be empty") + } +} diff --git a/Tests/InfomaniakCoreTests/Sendable/UTSendableProperty.swift b/Tests/InfomaniakCoreTests/Sendable/UTSendableProperty.swift new file mode 100644 index 0000000..936c794 --- /dev/null +++ b/Tests/InfomaniakCoreTests/Sendable/UTSendableProperty.swift @@ -0,0 +1,99 @@ +/* + Infomaniak Core - iOS + Copyright (C) 2024 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import InfomaniakCore +import XCTest + +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +final class MCKSendableProperty: XCTestCase { + @SendableProperty var protectedString: String? +} + +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +final class UTSendableProperty: XCTestCase { + func testMutateToNil() async { + // GIVEN + let mckSendableClass = MCKSendableProperty() + mckSendableClass.protectedString = "the fox jumps over the lazy dog" + XCTAssertNotNil(mckSendableClass.protectedString, "Sanity check") + + // WHEN + let t = Task.detached { + mckSendableClass.protectedString = nil + } + + _ = await t.result + + // THEN + XCTAssertNil(mckSendableClass.protectedString, "The mutation should be reflected") + } + + func testMutateConcurent() async { + // GIVEN + let mckSendableClass = MCKSendableProperty() + mckSendableClass.protectedString = nil + XCTAssertNil(mckSendableClass.protectedString, "Sanity check") + + // WHEN + let t = Task.detached { + mckSendableClass.protectedString = "t" + } + + let u = Task.detached { + mckSendableClass.protectedString = "u" + } + + let v = Task.detached { + mckSendableClass.protectedString = "v" + } + + _ = await t.result + _ = await u.result + _ = await v.result + + // THEN + XCTAssertNotNil(mckSendableClass.protectedString, "The mutation should be reflected") + } + + func testMutateSerial() async { + // GIVEN + let mckSendableClass = MCKSendableProperty() + mckSendableClass.protectedString = nil + XCTAssertNil(mckSendableClass.protectedString, "Sanity check") + + // WHEN + let t = Task.detached { + mckSendableClass.protectedString = "t" + + let u = Task.detached { + mckSendableClass.protectedString = "u" + + let v = Task.detached { + mckSendableClass.protectedString = "v" + } + _ = await v.result + } + _ = await u.result + } + _ = await t.result + + // THEN + XCTAssertNotNil(mckSendableClass.protectedString, "The mutation should be reflected") + XCTAssertEqual(mckSendableClass.protectedString, "v", "expecting to access the last value mutated") + } +} diff --git a/Tests/InfomaniakCoreTests/UTCollectionTests.swift b/Tests/InfomaniakCoreTests/UTCollectionTests.swift index ca5f922..1e12919 100644 --- a/Tests/InfomaniakCoreTests/UTCollectionTests.swift +++ b/Tests/InfomaniakCoreTests/UTCollectionTests.swift @@ -42,290 +42,3 @@ final class UTCollectionTests: XCTestCase { XCTAssertNil(fetched) } } - -// MARK: SendableArray - -@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) -final class UTSendableArray: XCTestCase { - func testInsertSubscript() async { - // GIVEN - let collection = SendableArray() - - // WHEN - let t = Task.detached { - collection[0] = "a" - collection[1] = "b" - collection[2] = "c" - } - - _ = await t.result - - // THEN - XCTAssertEqual(collection.count, 3) - } - - func testInsert() async { - // GIVEN - let collection = SendableArray() - - // WHEN - let t = Task.detached { - collection.append("a") - collection.append("b") - } - - _ = await t.result - - // THEN - XCTAssertEqual(collection.count, 2) - } - - func testUpdate() async { - // GIVEN - let collection = SendableArray() - - // WHEN - let t = Task.detached { - collection.append("a") - collection[0] = "b" - collection[1] = "c" - } - - _ = await t.result - - // THEN - XCTAssertEqual(collection.count, 2) - } - - func testRemoveAll() async { - // GIVEN - let collection = SendableArray() - collection.append("a") - collection.append("b") - - // WHEN - let t = Task.detached { - collection.removeAll() - } - - _ = await t.result - - // THEN - XCTAssertTrue(collection.isEmpty) - } - - func testRemoveAllWhere() async { - // GIVEN - let collection = SendableArray() - collection.append("a") - collection.append("b") - collection.append("c") - - // WHEN - let t = Task.detached { - collection.removeAll(where: { $0 == "b" }) - } - - _ = await t.result - - // THEN - XCTAssertFalse(collection.values.contains("b")) - } - - func testIterator() async { - // GIVEN - let collection = SendableArray() - collection.append("a") - collection.append("b") - - var iterator = collection.makeIterator() - - // WHEN - // We remove all items in the collection - let t = Task.detached { - collection.removeAll() - } - - await t.finish() - - // THEN - XCTAssertTrue(collection.values.isEmpty, "The collection is expected to be empty") - - // We can work with the captured enumeration - var isEmpty = true - while let value = iterator.next() { - isEmpty = false - - if value == "a" || value == "b" { - // OK - } else { - XCTFail("unexpected value:\(value)") - } - } - - XCTAssertFalse(isEmpty, "the iterator is not supposed to be empty") - } - - func testEnumerated() async { - // GIVEN - let collection = SendableArray() - collection.append("a") - collection.append("b") - - let collectionEnumerated = collection.enumerated() - - // WHEN - // We remove all items in the collection - let t = Task.detached { - collection.removeAll() - } - - await t.finish() - - // THEN - XCTAssertTrue(collection.values.isEmpty, "The collection is expected to be empty") - - // We can work with the captured enumeration - var isEmpty = true - for (index, value) in collectionEnumerated { - isEmpty = false - - if value == "a" || value == "b" { - // OK - } else { - XCTFail("unexpected value:\(value) at index:\(index)") - } - } - - XCTAssertFalse(isEmpty, "the iterator is not supposed to be empty") - } -} - -// MARK: SendableDictionary - -@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) -final class UTSendableDictionary: XCTestCase { - func testInsertSubscript() async { - // GIVEN - let collection = SendableDictionary() - - // WHEN - let t = Task.detached { - collection["a"] = 1 - collection["b"] = 2 - } - - _ = await t.result - - // THEN - XCTAssertEqual(collection.count, 2) - } - - func testInsert() async { - // GIVEN - let collection = SendableDictionary() - - // WHEN - let t = Task.detached { - collection.setValue(1, for: "a") - collection.setValue(2, for: "b") - } - - _ = await t.result - - // THEN - XCTAssertEqual(collection.count, 2) - } - - func testRemoveAll() async { - // GIVEN - let collection = SendableDictionary() - collection.setValue(1, for: "a") - collection.setValue(2, for: "b") - - // WHEN - let t = Task.detached { - collection.removeAll() - } - - _ = await t.result - - // THEN - XCTAssertTrue(collection.values.isEmpty) - } - - func testIterator() async { - // GIVEN - let collection = SendableDictionary() - collection.setValue(1, for: "a") - collection.setValue(2, for: "b") - - var iterator = collection.makeIterator() - - // WHEN - // We remove all items in the collection - let t = Task.detached { - collection.removeAll() - } - - await t.finish() - - // THEN - XCTAssertTrue(collection.values.isEmpty, "The collection is expected to be empty") - - // We can work with the captured enumeration - var isEmpty = true - while let (key, value) = iterator.next() { - isEmpty = false - - if key == "a" { - XCTAssertEqual(value, 1) - } else if key == "b" { - XCTAssertEqual(value, 2) - } else { - XCTFail("unexpected key:\(key) value:\(value)") - } - } - - XCTAssertFalse(isEmpty, "the iterator is not supposed to be empty") - } - - func testEnumerated() async { - // GIVEN - let collection = SendableDictionary() - collection.setValue(1, for: "a") - collection.setValue(2, for: "b") - - let collectionEnumerated = collection.enumerated() - - // WHEN - // We remove all items in the collection - let t = Task.detached { - collection.removeAll() - } - - await t.finish() - - // THEN - XCTAssertTrue(collection.values.isEmpty, "The collection is expected to be empty") - - // We can work with the captured enumeration - var isEmpty = true - for (index, node) in collectionEnumerated { - isEmpty = false - - let key = node.0 - let value = node.1 - - if key == "a" { - XCTAssertEqual(value, 1) - } else if key == "b" { - XCTAssertEqual(value, 2) - } else { - XCTFail("unexpected key:\(key) value:\(value) at index:\(index) ") - } - } - - XCTAssertFalse(isEmpty, "the iterator is not supposed to be empty") - } -} From c11049774691294380e948daf983fe7282731704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrien=20Coye=20de=20Brune=CC=81lis?= Date: Wed, 29 May 2024 14:20:05 +0200 Subject: [PATCH 2/3] docs: Document SendableProperty --- .../Asynchronous/Sendable/SendableProperty.swift | 5 ++++- Tests/InfomaniakCoreTests/Sendable/UTSendableProperty.swift | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/InfomaniakCore/Asynchronous/Sendable/SendableProperty.swift b/Sources/InfomaniakCore/Asynchronous/Sendable/SendableProperty.swift index 9d44583..c8f3001 100644 --- a/Sources/InfomaniakCore/Asynchronous/Sendable/SendableProperty.swift +++ b/Sources/InfomaniakCore/Asynchronous/Sendable/SendableProperty.swift @@ -18,13 +18,16 @@ import Foundation +/// Making a property thread safe, while not requiring `await`. Conforms to Sendable. +/// +/// Please prefer using first party structured concurrency. Use this for prototyping or dealing with race conditions. @propertyWrapper @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) public final class SendableProperty: @unchecked Sendable { /// Serial locking queue let lock = DispatchQueue(label: "com.infomaniak.core.SendableProperty.lock") - /// Store the resolved service + /// Store property var property: Property? public init() { diff --git a/Tests/InfomaniakCoreTests/Sendable/UTSendableProperty.swift b/Tests/InfomaniakCoreTests/Sendable/UTSendableProperty.swift index 936c794..802ba9d 100644 --- a/Tests/InfomaniakCoreTests/Sendable/UTSendableProperty.swift +++ b/Tests/InfomaniakCoreTests/Sendable/UTSendableProperty.swift @@ -19,6 +19,7 @@ import InfomaniakCore import XCTest +/// Example class that protects access to a property. @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) final class MCKSendableProperty: XCTestCase { @SendableProperty var protectedString: String? From 5cef163741aabec03a2c227267cd3711cdfb26c5 Mon Sep 17 00:00:00 2001 From: adrien-coye Date: Wed, 29 May 2024 14:26:40 +0200 Subject: [PATCH 3/3] chore: Update Sources/InfomaniakCore/Asynchronous/Sendable/SendableProperty.swift Co-authored-by: Philippe Weidmann --- .../Asynchronous/Sendable/SendableProperty.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Sources/InfomaniakCore/Asynchronous/Sendable/SendableProperty.swift b/Sources/InfomaniakCore/Asynchronous/Sendable/SendableProperty.swift index c8f3001..01236d6 100644 --- a/Sources/InfomaniakCore/Asynchronous/Sendable/SendableProperty.swift +++ b/Sources/InfomaniakCore/Asynchronous/Sendable/SendableProperty.swift @@ -30,9 +30,7 @@ public final class SendableProperty: @unchecked Sendable { /// Store property var property: Property? - public init() { - // META: Sonar Cloud happy - } + public init() { } public var wrappedValue: Property? { get {