From 5220fbfc8e7debfee63d980239d298c09bdda89f Mon Sep 17 00:00:00 2001 From: Joseph Heck <heckj@mac.com> Date: Thu, 2 May 2024 16:41:21 -0700 Subject: [PATCH] sendable conformance for AutomergeText and Counter (#160) - enabled using WASI compatible dispatch-queue like structure - wrapped access points in internal sync{} call that uses the local serialqueue --- .../Automerge/BoundTypes/AutomergeText.swift | 92 +++++++++++----- Sources/Automerge/BoundTypes/Counter.swift | 100 ++++++++++++------ 2 files changed, 134 insertions(+), 58 deletions(-) diff --git a/Sources/Automerge/BoundTypes/AutomergeText.swift b/Sources/Automerge/BoundTypes/AutomergeText.swift index d7c4f641..6374230e 100644 --- a/Sources/Automerge/BoundTypes/AutomergeText.swift +++ b/Sources/Automerge/BoundTypes/AutomergeText.swift @@ -34,7 +34,7 @@ import Foundation /// /// > Warning: Although `AutomergeText` conforms to `ObservableObject`, it does not send notifications of content /// changes until it has been bound to an Automerge document. -public final class AutomergeText: Codable { +public final class AutomergeText: Codable, @unchecked Sendable { var doc: Document? var objId: ObjId? var _hashOfCurrentValue: Int @@ -43,6 +43,17 @@ public final class AutomergeText: Codable { #endif var _unboundStorage: String + #if !os(WASI) + fileprivate let queue = DispatchQueue(label: "automergetext-sync-queue", qos: .userInteractive) + fileprivate func sync<T>(execute work: () throws -> T) rethrows -> T { + try queue.sync(execute: work) + } + #else + fileprivate func sync<T>(execute work: () throws -> T) rethrows -> T { + try work() + } + #endif + // MARK: Initializers and Bind /// Creates a new, unbound text reference instance. @@ -111,8 +122,10 @@ public final class AutomergeText: Codable { public convenience init(doc: Document, objId: ObjId) throws { self.init() if doc.objectType(obj: objId) == .Text { - self.doc = doc - self.objId = objId + sync { + self.doc = doc + self.objId = objId + } } else { throw BindingError.NotText } @@ -133,7 +146,7 @@ public final class AutomergeText: Codable { /// Use ``bind(doc:path:)`` to associate this instance with a specific schema location within an Automerge document, /// or encode it as part of a larger document model into an Automerge document to store the value. public var isBound: Bool { - doc != nil && objId != nil + sync { doc != nil && objId != nil } } /// Binds a text reference instance info an Automerge document with the schema path you provide. @@ -155,8 +168,10 @@ public final class AutomergeText: Codable { .InvalidPath("First path element in an Automerge document can't be an index position.") } let textObjId = try doc.putObject(obj: ObjId.ROOT, key: codingPath[0].stringValue, ty: .Text) - self.doc = doc - objId = textObjId + sync { + self.doc = doc + objId = textObjId + } } else { guard let lastPathElement = codingPath.last else { throw BindingError.InvalidPath("Unable to request a final path element from path \(path)") @@ -174,16 +189,20 @@ public final class AutomergeText: Codable { index: UInt64(indexLocation), ty: .Text ) - self.doc = doc - objId = textObjId + sync { + self.doc = doc + objId = textObjId + } } else { let textObjId = try doc.putObject( obj: secondToLastPathItemObjId, key: lastPathElement.stringValue, ty: .Text ) - self.doc = doc - objId = textObjId + sync { + self.doc = doc + objId = textObjId + } } case let .failure(failure): throw failure @@ -191,7 +210,9 @@ public final class AutomergeText: Codable { } if !_unboundStorage.isEmpty { try updateText(newText: _unboundStorage) - _unboundStorage = "" + sync { + _unboundStorage = "" + } } observeDocForChanges() } @@ -205,14 +226,18 @@ public final class AutomergeText: Codable { /// - path: A string path that represents a `Text` container within the Automerge document. public func bind(doc: Document, id: ObjId) throws { if doc.objectType(obj: id) == .Text { - self.doc = doc - objId = id + sync { + self.doc = doc + objId = id + } } else { throw BindingError.NotText } if !_unboundStorage.isEmpty { try updateText(newText: _unboundStorage) - _unboundStorage = "" + sync { + _unboundStorage = "" + } } observeDocForChanges() } @@ -240,7 +265,8 @@ public final class AutomergeText: Codable { // a change notification. Task { let valueFromDoc = try doc.text(obj: objId) - if valueFromDoc.hashValue != self._hashOfCurrentValue { + let hashOfCurrentValue = self.sync { self._hashOfCurrentValue } + if valueFromDoc.hashValue != hashOfCurrentValue { self.sendObjectWillChange() } } @@ -253,18 +279,22 @@ public final class AutomergeText: Codable { /// The string value of the text reference in an Automerge document. public var value: String { get { - guard let doc, let objId else { - return _unboundStorage - } - do { - return try doc.text(obj: objId) - } catch { - fatalError("Error attempting to read text value from objectId \(objId): \(error)") + sync { + guard let doc, let objId else { + return _unboundStorage + } + do { + return try doc.text(obj: objId) + } catch { + fatalError("Error attempting to read text value from objectId \(objId): \(error)") + } } } set { guard let objId, doc != nil else { - _unboundStorage = newValue + sync { + _unboundStorage = newValue + } return } do { @@ -281,7 +311,9 @@ public final class AutomergeText: Codable { } let current = try doc.text(obj: objId) if current != newText { - _hashOfCurrentValue = newText.hashValue + sync { + _hashOfCurrentValue = newText.hashValue + } try doc.updateText(obj: objId, value: newText) sendObjectWillChange() } @@ -317,8 +349,10 @@ extension AutomergeText: Equatable { extension AutomergeText: Hashable { public func hash(into hasher: inout Hasher) { - hasher.combine(objId) - hasher.combine(_unboundStorage) + sync { + hasher.combine(objId) + hasher.combine(_unboundStorage) + } } } @@ -352,7 +386,7 @@ public extension AutomergeText { Binding( get: { () -> String in guard let doc = self.doc, let objId = self.objId else { - return self._unboundStorage + return self.sync { self._unboundStorage } } do { return try doc.text(obj: objId) @@ -362,7 +396,9 @@ public extension AutomergeText { }, set: { (newValue: String) in guard let objId = self.objId, self.doc != nil else { - self._unboundStorage = newValue + self.sync { + self._unboundStorage = newValue + } return } do { diff --git a/Sources/Automerge/BoundTypes/Counter.swift b/Sources/Automerge/BoundTypes/Counter.swift index 9e97dabb..bd81fc1e 100644 --- a/Sources/Automerge/BoundTypes/Counter.swift +++ b/Sources/Automerge/BoundTypes/Counter.swift @@ -8,7 +8,7 @@ import Foundation /// /// As a reference type, `Counter` updates the underlying Automerge document when a value is explicitly /// set, or ``increment(by:)`` is called on the instance. -public final class Counter: Codable { +public final class Counter: Codable, @unchecked Sendable { var doc: Document? var objId: ObjId? var codingkey: AnyCodingKey? @@ -18,6 +18,17 @@ public final class Counter: Codable { #endif var _unboundStorage: Int + #if !os(WASI) + fileprivate let queue = DispatchQueue(label: "automergecounter-sync-queue", qos: .userInteractive) + fileprivate func sync<T>(execute work: () throws -> T) rethrows -> T { + try queue.sync(execute: work) + } + #else + fileprivate func sync<T>(execute work: () throws -> T) rethrows -> T { + try work() + } + #endif + // MARK: Initializers and Bind /// Creates a new, unbound counter. @@ -49,18 +60,24 @@ public final class Counter: Codable { self.init() // TODO: convert this, akin to Text, to create an instance at the path provided if let index = key.intValue { - if case .Scalar(.Counter(_)) = try doc.get(obj: objId, index: UInt64(index)) { - self.doc = doc - self.objId = objId - codingkey = AnyCodingKey(key) + if case let .Scalar(.Counter(counterValue)) = try doc.get(obj: objId, index: UInt64(index)) { + sync { + self.doc = doc + self.objId = objId + codingkey = AnyCodingKey(key) + _hashOfCurrentValue = counterValue.hashValue + } } else { throw BindingError.NotCounter } } else { - if case .Scalar(.Counter) = try doc.get(obj: objId, key: key.stringValue) { - self.doc = doc - self.objId = objId - codingkey = AnyCodingKey(key) + if case let .Scalar(.Counter(counterValue)) = try doc.get(obj: objId, key: key.stringValue) { + sync { + self.doc = doc + self.objId = objId + codingkey = AnyCodingKey(key) + _hashOfCurrentValue = counterValue.hashValue + } } else { throw BindingError.NotCounter } @@ -76,7 +93,9 @@ public final class Counter: Codable { /// Returns a Boolean value that indicates wether this reference type is actively updating an Automerge document. public var isBound: Bool { - doc != nil && objId != nil + sync { + doc != nil && objId != nil + } } /// Binds a text reference instance info an Automerge document. @@ -101,32 +120,42 @@ public final class Counter: Codable { } if let index = key.intValue { if case .Scalar(.Counter) = try doc.get(obj: objId, index: UInt64(index)) { - self.doc = doc - self.objId = objId - codingkey = key - if _unboundStorage != 0 { + sync { + self.doc = doc + self.objId = objId + codingkey = key + } + let currentUnboundValue = sync { self._unboundStorage } + if currentUnboundValue != 0 { // If the unbound counter has been adjusted, positive or negative, use // that as an increment value on the existing counter to ensure that // all the counter changes are maintained and appended to each other. try doc.increment(obj: objId, index: UInt64(index), by: Int64(_unboundStorage)) sendObjectWillChange() - _unboundStorage = 0 + sync { + _unboundStorage = 0 + } } } else { throw BindingError.NotCounter } } else { if case .Scalar(.Counter) = try doc.get(obj: objId, key: key.stringValue) { - self.doc = doc - self.objId = objId - codingkey = key - if _unboundStorage != 0 { + sync { + self.doc = doc + self.objId = objId + codingkey = key + } + let currentUnboundValue = sync { self._unboundStorage } + if currentUnboundValue != 0 { // If the unbound counter has been adjusted, positive or negative, use // that as an increment value on the existing counter to ensure that // all the counter changes are maintained and appended to each other. try doc.increment(obj: objId, key: key.stringValue, by: Int64(_unboundStorage)) sendObjectWillChange() - _unboundStorage = 0 + sync { + _unboundStorage = 0 + } } } else { throw BindingError.NotCounter @@ -156,9 +185,10 @@ public final class Counter: Codable { // This is firing off in a concurrent task explicitly to leave the synchronous // context that can happen when a doc is being updated and Combine is triggering // a change notification. + let _hashOfCurrentValue = sync { self._hashOfCurrentValue } Task { let currentValue = self.getCounterValue() - if currentValue.hashValue != self._hashOfCurrentValue { + if currentValue.hashValue != _hashOfCurrentValue { self.sendObjectWillChange() } } @@ -180,7 +210,9 @@ public final class Counter: Codable { fileprivate func getCounterValue() -> Int { guard let doc, let objId, let codingkey else { - return _unboundStorage + return sync { + _unboundStorage + } } do { if let index = codingkey.intValue { @@ -199,9 +231,9 @@ public final class Counter: Codable { } fileprivate func setCounterValue(_ intValue: Int) { - _hashOfCurrentValue = intValue.hashValue + _hashOfCurrentValue = sync { intValue.hashValue } guard let objId, let doc, let codingkey else { - _unboundStorage = intValue + sync { _unboundStorage = intValue } return } do { @@ -231,15 +263,19 @@ public final class Counter: Codable { /// - Parameter value: The value to add (or subtract) from the counter. public func increment(by value: Int) { guard let objId, let doc, let codingkey else { - _unboundStorage += value - _hashOfCurrentValue = _unboundStorage.hashValue + sync { + _unboundStorage += value + _hashOfCurrentValue = _unboundStorage.hashValue + } return } do { if let index = codingkey.intValue { if case let .Scalar(.Counter(currentValue)) = try doc.get(obj: objId, index: UInt64(index)) { try doc.increment(obj: objId, index: UInt64(index), by: Int64(value)) - _hashOfCurrentValue = (Int(currentValue) + value).hashValue + sync { + _hashOfCurrentValue = (Int(currentValue) + value).hashValue + } sendObjectWillChange() } else { throw BindingError.NotCounter @@ -247,7 +283,9 @@ public final class Counter: Codable { } else { if case let .Scalar(.Counter(currentValue)) = try doc.get(obj: objId, key: codingkey.stringValue) { try doc.increment(obj: objId, key: codingkey.stringValue, by: Int64(value)) - _hashOfCurrentValue = (Int(currentValue) + value).hashValue + sync { + _hashOfCurrentValue = (Int(currentValue) + value).hashValue + } sendObjectWillChange() } else { throw BindingError.NotCounter @@ -299,8 +337,10 @@ extension Counter: Equatable { extension Counter: Hashable { public func hash(into hasher: inout Hasher) { - hasher.combine(objId) - hasher.combine(value) + sync { + hasher.combine(objId) + hasher.combine(value) + } } }