diff --git a/Sources/SKLogging/NonDarwinLogging.swift b/Sources/SKLogging/NonDarwinLogging.swift index 764bb8db4..cf7e8b120 100644 --- a/Sources/SKLogging/NonDarwinLogging.swift +++ b/Sources/SKLogging/NonDarwinLogging.swift @@ -10,7 +10,7 @@ // //===----------------------------------------------------------------------===// -import Synchronization +public import Synchronization @_spi(SourceKitLSP) import ToolsProtocolsSwiftExtensions #if canImport(Darwin) @@ -23,54 +23,45 @@ import Foundation // MARK: - Log settings @_spi(SourceKitLSP) @frozen public enum LogConfig { - /// The globally set log level - private static let _logLevel = ThreadSafeBox( - initialValue: { - if let envVar = ProcessInfo.processInfo.environment["SOURCEKIT_LSP_LOG_LEVEL"], - let logLevel = NonDarwinLogLevel(envVar) - { - return logLevel - } - #if DEBUG - return .debug - #else - return .default - #endif - }() - ) - - @_spi(SourceKitLSP) public static var logLevel: NonDarwinLogLevel { - get { - _logLevel.value + private static var initialLogLevel: NonDarwinLogLevel { + if let envVar = ProcessInfo.processInfo.environment["SOURCEKIT_LSP_LOG_LEVEL"], + let logLevel = NonDarwinLogLevel(envVar) + { + return logLevel } - set { - _logLevel.value = newValue + #if DEBUG + return .debug + #else + return .default + #endif + } + + private static var initialPrivacyLevel: NonDarwinLogPrivacy { + if let envVar = ProcessInfo.processInfo.environment["SOURCEKIT_LSP_LOG_PRIVACY_LEVEL"], + let privacyLevel = NonDarwinLogPrivacy(envVar) + { + return privacyLevel } + #if DEBUG + return .private + #else + return .public + #endif } - /// The globally set privacy level - private static let _privacyLevel = ThreadSafeBox( - initialValue: { - if let envVar = ProcessInfo.processInfo.environment["SOURCEKIT_LSP_LOG_PRIVACY_LEVEL"], - let privacyLevel = NonDarwinLogPrivacy(envVar) - { - return privacyLevel - } - #if DEBUG - return .private - #else - return .public - #endif - }() - ) + private static let _logLevel = RefBox(Atomic(initialLogLevel)) + private static let _privacyLevel = RefBox(Atomic(initialPrivacyLevel)) + /// The globally set log level + @_spi(SourceKitLSP) public static var logLevel: NonDarwinLogLevel { + get { _logLevel.value.load(ordering: .relaxed) } + set { _logLevel.value.store(newValue, ordering: .relaxed) } + } + + /// The globally set privacy level @_spi(SourceKitLSP) public static var privacyLevel: NonDarwinLogPrivacy { - get { - _privacyLevel.value - } - set { - _privacyLevel.value = newValue - } + get { _privacyLevel.value.load(ordering: .relaxed) } + set { _privacyLevel.value.store(newValue, ordering: .relaxed) } } } @@ -81,13 +72,18 @@ import Foundation /// /// For documentation of the different log levels see /// https://developer.apple.com/documentation/os/oslogtype. -@_spi(SourceKitLSP) @frozen public enum NonDarwinLogLevel: Comparable, CustomStringConvertible, Sendable { +@_spi(SourceKitLSP) @frozen +public enum NonDarwinLogLevel: UInt8, Comparable, CustomStringConvertible, AtomicRepresentable, Sendable { case debug case info case `default` case error case fault + public static func < (lhs: NonDarwinLogLevel, rhs: NonDarwinLogLevel) -> Bool { + lhs.rawValue < rhs.rawValue + } + @_spi(SourceKitLSP) public init?(_ value: String) { switch value.lowercased() { case "debug": self = .debug @@ -138,11 +134,15 @@ import Foundation /// /// For documentation of the different privacy levels see /// https://developer.apple.com/documentation/os/oslogprivacy. -@_spi(SourceKitLSP) @frozen public enum NonDarwinLogPrivacy: Comparable, Sendable { +@_spi(SourceKitLSP) @frozen public enum NonDarwinLogPrivacy: UInt8, Comparable, Sendable, AtomicRepresentable { case `public` case `private` case sensitive + @_spi(SourceKitLSP) public static func < (lhs: NonDarwinLogPrivacy, rhs: NonDarwinLogPrivacy) -> Bool { + lhs.rawValue < rhs.rawValue + } + @_spi(SourceKitLSP) public init?(_ value: String) { switch value.lowercased() { case "sensitive": self = .sensitive diff --git a/Sources/ToolsProtocolsSwiftExtensions/AsyncUtils.swift b/Sources/ToolsProtocolsSwiftExtensions/AsyncUtils.swift index b7f30e078..6b34e7147 100644 --- a/Sources/ToolsProtocolsSwiftExtensions/AsyncUtils.swift +++ b/Sources/ToolsProtocolsSwiftExtensions/AsyncUtils.swift @@ -121,7 +121,8 @@ extension Task where Failure == Never { operation: { try Task.checkCancellation() return try await withCheckedThrowingContinuation { continuation in - handleWrapper.value = operation(continuation) + let handle = operation(continuation) + handleWrapper.withLock { $0 = handle } // Check if the task was cancelled. This ensures we send a // CancelNotification even if the task gets cancelled after we register @@ -285,7 +286,7 @@ package func withTimeout( let stream = AsyncThrowingStream { continuation in Task { try await Task.sleep(for: timeout) - didHitTimeout.value = true + didHitTimeout.withLock { $0 = true } continuation.yield(nil) } diff --git a/Sources/ToolsProtocolsSwiftExtensions/CMakeLists.txt b/Sources/ToolsProtocolsSwiftExtensions/CMakeLists.txt index a89ce6546..7e6f21697 100644 --- a/Sources/ToolsProtocolsSwiftExtensions/CMakeLists.txt +++ b/Sources/ToolsProtocolsSwiftExtensions/CMakeLists.txt @@ -4,8 +4,8 @@ set(sources Collection+Only.swift Duration+Seconds.swift FileManagerExtensions.swift - NSLock+WithLock.swift PipeAsStringHandler.swift + RefBox.swift Task+WithPriorityChangedHandler.swift ThreadSafeBox.swift URLExtensions.swift diff --git a/Sources/ToolsProtocolsSwiftExtensions/NSLock+WithLock.swift b/Sources/ToolsProtocolsSwiftExtensions/NSLock+WithLock.swift deleted file mode 100644 index 9c0d9bd6e..000000000 --- a/Sources/ToolsProtocolsSwiftExtensions/NSLock+WithLock.swift +++ /dev/null @@ -1,21 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -import Foundation - -extension NSLock { - func withLock(_ body: () throws -> T) rethrows -> T { - lock() - defer { unlock() } - return try body() - } -} diff --git a/Sources/ToolsProtocolsSwiftExtensions/RefBox.swift b/Sources/ToolsProtocolsSwiftExtensions/RefBox.swift new file mode 100644 index 000000000..ceb194d56 --- /dev/null +++ b/Sources/ToolsProtocolsSwiftExtensions/RefBox.swift @@ -0,0 +1,24 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// A reference-typed container for a single value, useful for sharing non-copyable +/// values (such as `Mutex` or `Atomic`) by reference — including capturing them +/// in `@Sendable` closures, which can't otherwise capture `~Copyable` types. +@_spi(SourceKitLSP) public final class RefBox { + public let value: Value + + @inlinable public init(_ value: consuming Value) { + self.value = value + } +} + +extension RefBox: Sendable where Value: ~Copyable & Sendable {} diff --git a/Sources/ToolsProtocolsSwiftExtensions/ThreadSafeBox.swift b/Sources/ToolsProtocolsSwiftExtensions/ThreadSafeBox.swift index bc0ec9be4..aaf656e94 100644 --- a/Sources/ToolsProtocolsSwiftExtensions/ThreadSafeBox.swift +++ b/Sources/ToolsProtocolsSwiftExtensions/ThreadSafeBox.swift @@ -2,7 +2,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Copyright (c) 2014 - 2026 Apple Inc. and the Swift project authors // Licensed under Apache License v2.0 with Runtime Library Exception // // See https://swift.org/LICENSE.txt for license information @@ -10,52 +10,44 @@ // //===----------------------------------------------------------------------===// -import Foundation +public import Synchronization -/// A thread safe container that contains a value of type `T`. +/// A wrapper around a heap-allocated `RefBox>`, providing ergonomic +/// thread-safe access to a mutable shared state. /// -/// - Note: Unchecked sendable conformance because value is guarded by a lock. -package class ThreadSafeBox: @unchecked Sendable { - /// Lock guarding `_value`. - private let lock = NSLock() +/// `var value` is read-only. Writes must go through ``withLock(_:)`` so that +/// read-modify-write patterns (e.g. `+=`, `append`) hold the lock for the +/// entire operation rather than acquiring it twice. +@_spi(SourceKitLSP) @frozen public struct ThreadSafeBox: Sendable { + @usableFromInline let box: RefBox> - private var _value: T - - package var value: T { - get { - return lock.withLock { - return _value - } - } - set { - lock.withLock { - _value = newValue - } - } - _modify { - lock.lock() - defer { lock.unlock() } - yield &_value - } + @inlinable public init(initialValue: consuming sending Value) { + self.box = RefBox(Mutex(initialValue)) } - package init(initialValue: T) { - _value = initialValue + @inlinable public func withLock( + _ body: (inout sending Value) throws(E) -> sending Result + ) throws(E) -> sending Result { + try box.value.withLock(body) } +} - package func withLock(_ body: (inout T) throws -> Result) rethrows -> Result { - return try lock.withLock { - return try body(&_value) - } +extension ThreadSafeBox where Value: Sendable { + /// Atomically reads the wrapped value. Writes must go through ``withLock(_:)``. + @inlinable public var value: Value { + withLock { $0 } } +} - /// If the value in the box is an optional, return it and reset it to `nil` - /// in an atomic operation. - package func takeValue() -> T where U? == T { - lock.withLock { - guard let value = self._value else { return nil } - self._value = nil - return value +extension ThreadSafeBox { + /// Atomically reads the wrapped optional value and resets it to `nil`. + @inlinable public func takeValue() -> sending Wrapped? where Value == Wrapped? { + withLock { state in + // Don't use `Optional.take()` because the compiler can't see through `take()` + // to prove the returned value is disjoint from `state`. + let result = state + state = nil + return result } } } diff --git a/Tests/ToolsProtocolsSwiftExtensionsTests/AsyncQueueTests.swift b/Tests/ToolsProtocolsSwiftExtensionsTests/AsyncQueueTests.swift index 83ed415c3..6bcdaab39 100644 --- a/Tests/ToolsProtocolsSwiftExtensionsTests/AsyncQueueTests.swift +++ b/Tests/ToolsProtocolsSwiftExtensionsTests/AsyncQueueTests.swift @@ -66,7 +66,7 @@ struct AsyncQueueTests { let serialRan = ThreadSafeBox(initialValue: false) let serialTask = queue.async(metadata: .serial) { - serialRan.value = true + serialRan.withLock { $0 = true } } // Release only the last concurrent task. The serial task must still wait diff --git a/Tests/ToolsProtocolsSwiftExtensionsTests/RefBoxTests.swift b/Tests/ToolsProtocolsSwiftExtensionsTests/RefBoxTests.swift new file mode 100644 index 000000000..e3f2d8a23 --- /dev/null +++ b/Tests/ToolsProtocolsSwiftExtensionsTests/RefBoxTests.swift @@ -0,0 +1,45 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Synchronization +import Testing +@_spi(SourceKitLSP) import ToolsProtocolsSwiftExtensions + +struct RefBoxTests { + /// `RefBox>` is `Sendable` and can be captured by a detached task. + @Test func mutexBoxIsSendableAndShareable() async { + let box = RefBox(Mutex(0)) + let task = Task { box.value.withLock { $0 += 1 } } + await task.value + #expect(box.value.withLock { $0 } == 1) + } + + /// `withLock` returns the body's result and propagates mutations. + @Test func mutexBoxWithLockReturnsAndMutates() { + let box = RefBox(Mutex<[Int]>([])) + let count = box.value.withLock { (arr: inout [Int]) -> Int in + arr.append(1) + arr.append(2) + return arr.count + } + #expect(count == 2) + #expect(box.value.withLock { $0 } == [1, 2]) + } + + /// `RefBox>` is `Sendable` and shareable across tasks. + @Test func atomicBoxIsSendableAndShareable() async { + let flag = RefBox(Atomic(false)) + let task = Task { flag.value.store(true, ordering: .relaxed) } + await task.value + #expect(flag.value.load(ordering: .relaxed) == true) + } +}