Skip to content
Merged
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
9 changes: 9 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,15 @@ custom_rules:
excluded: ".*/(AppStorageEnvironment|GeneralSettings|SoftwareUpdater|WorkspaceRailViewController|SplitViewAutosaveSweep)\\.swift"
message: "Resolve preferences through AppStorageEnvironment.shared.defaults. Only a macOS-owned system preference reads the standard domain."
severity: error
# Package code cannot reach AppStorageEnvironment and the iOS app has no sandbox, so `.standard` is
# their only default. ManagedPolicy reads MDM-managed keys, which live in the standard domain.
storage_environment_defaults_argument:
name: "Storage Environment Defaults Argument"
regex: '\bUserDefaults[?!]?\s*=\s*\.standard\b'
excluded: ['.*/(Packages|Plugins|TableProMobile)/', '.*/ManagedPolicy\.swift']
excluded_match_kinds: [comment, doccomment]
message: "Default a UserDefaults parameter to AppStorageEnvironment.shared.defaults, or a sandboxed run writes the developer's own com.TablePro."
severity: error
public_error_text_in_log:
name: "Error Text In The System Log"
regex: '(\.(localizedDescription|errorDescription|failureReason)(\.prefix\(\d+\))?(\s*\?\?\s*"[^"]*")?|String\(describing:\s*\w*[eE]rr(or)?\)|\\\((?!(has|is|did|was|should)[A-Z])(\w*[eE]rr(or)?|e)(\.(message|description|debugDescription))?)\s*,\s*privacy:\s*\.public'
Expand Down
8 changes: 4 additions & 4 deletions Packages/TableProCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ let package = Package(
),
.testTarget(
name: "TableProCoreTypesTests",
dependencies: ["TableProCoreTypes"],
dependencies: ["TableProCoreTypes", "TableProTestSupport"],
path: "Tests/TableProCoreTypesTests"
),
.testTarget(
Expand Down Expand Up @@ -289,7 +289,7 @@ let package = Package(
),
.testTarget(
name: "TableProDatabaseTests",
dependencies: ["TableProDatabase", "TableProModels", "TableProPluginKit"],
dependencies: ["TableProDatabase", "TableProModels", "TableProPluginKit", "TableProTestSupport"],
path: "Tests/TableProDatabaseTests"
),
.testTarget(
Expand All @@ -309,7 +309,7 @@ let package = Package(
),
.testTarget(
name: "TableProMSSQLCoreTests",
dependencies: ["TableProMSSQLCore"],
dependencies: ["TableProMSSQLCore", "TableProTestSupport"],
path: "Tests/TableProMSSQLCoreTests"
),
.testTarget(
Expand All @@ -319,7 +319,7 @@ let package = Package(
),
.testTarget(
name: "TableProGoogleCloudTests",
dependencies: ["TableProGoogleCloud"],
dependencies: ["TableProGoogleCloud", "TableProTestSupport"],
path: "Tests/TableProGoogleCloudTests"
),
.testTarget(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public final class ConnectionManager: @unchecked Sendable {
private let secureStore: SecureStore
private let sshProvider: SSHProvider?
private let teardownWaitLimit: Duration
private let clock: any Clock<Duration>

private let lock = NSLock()
private var sessions: [UUID: ConnectionSession] = [:]
Expand All @@ -20,12 +21,14 @@ public final class ConnectionManager: @unchecked Sendable {
driverFactory: DriverFactory,
secureStore: SecureStore,
sshProvider: SSHProvider? = nil,
teardownWaitLimit: Duration = ConnectionManager.defaultTeardownWaitLimit
teardownWaitLimit: Duration = ConnectionManager.defaultTeardownWaitLimit,
clock: any Clock<Duration> = ContinuousClock()
) {
self.driverFactory = driverFactory
self.secureStore = secureStore
self.sshProvider = sshProvider
self.teardownWaitLimit = teardownWaitLimit
self.clock = clock
}

/// Opens a session, waiting out a teardown still running for the same connection first.
Expand Down Expand Up @@ -152,18 +155,22 @@ public final class ConnectionManager: @unchecked Sendable {
case .untilFinished:
return await teardown.waitUntilFinished() ? .cleared : .cancelled
case .after(let limit):
return await Self.wait(on: teardown, within: limit)
return await Self.wait(on: teardown, within: limit, clock: clock)
}
}

private static func wait(on teardown: ConnectionTeardown, within limit: Duration) async -> TeardownWait {
private static func wait(
on teardown: ConnectionTeardown,
within limit: Duration,
clock: any Clock<Duration>
) async -> TeardownWait {
await withTaskGroup(of: TeardownWait.self) { group in
group.addTask {
await teardown.waitUntilFinished() ? .cleared : .cancelled
}
group.addTask {
do {
try await Task.sleep(for: limit)
try await clock.sleep(for: limit)
} catch {
return .cancelled
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
@testable import TableProCoreTypes
import TableProTestSupport
import Testing

private final class FlagBox: @unchecked Sendable {
Expand All @@ -14,56 +15,8 @@ private final class FlagBox: @unchecked Sendable {
}
}

private func waitUntil(
timeout: Duration = .seconds(2),
_ condition: @Sendable () -> Bool
) async -> Bool {
let deadline = ContinuousClock.now.advanced(by: timeout)
while ContinuousClock.now < deadline {
if condition() { return true }
try? await Task.sleep(for: .milliseconds(5))
}
return condition()
}

private struct TimeoutError: Error {}

private enum TestWatchdog {
private final class FirstArrival<Value: Sendable>: @unchecked Sendable {
private let lock = NSLock()
private var continuation: CheckedContinuation<Value?, Never>?

init(_ continuation: CheckedContinuation<Value?, Never>) {
self.continuation = continuation
}

func claim() -> CheckedContinuation<Value?, Never>? {
lock.withLock {
defer { continuation = nil }
return continuation
}
}
}

static func result<Value: Sendable>(
within timeout: Duration = .seconds(2),
of work: @escaping @Sendable () async -> Value
) async -> Value? {
await withCheckedContinuation { continuation in
let arrival = FirstArrival(continuation)
let timer = Task {
try? await Task.sleep(for: timeout)
arrival.claim()?.resume(returning: nil)
}
Task {
let value = await work()
arrival.claim()?.resume(returning: value)
timer.cancel()
}
}
}
}

@Suite("Cancellable blocking work")
struct CancellableBlockingWorkTests {
@Test("Work that completes normally returns its value")
Expand Down Expand Up @@ -97,10 +50,10 @@ struct CancellableBlockingWorkTests {
if !didRelease { release.signal() }
}

try #require(await waitUntil { workStarted.value })
try await waitUntil { workStarted.value }
task.cancel()

let result = try #require(await TestWatchdog.result { await task.result })
let result = try #require(await BoundedCall.result(within: .seconds(2)) { await task.result })
switch result {
case .failure(let error):
#expect(error is CancellationError)
Expand All @@ -111,7 +64,7 @@ struct CancellableBlockingWorkTests {

didRelease = true
release.signal()
#expect(await waitUntil { discarded.value })
try await waitUntil { discarded.value }
}

@Test("A deadline fails the caller and discards the late result")
Expand All @@ -138,7 +91,7 @@ struct CancellableBlockingWorkTests {
if !didRelease { release.signal() }
}

let result = try #require(await TestWatchdog.result { await task.result })
let result = try #require(await BoundedCall.result(within: .seconds(2)) { await task.result })

switch result {
case .failure(let error):
Expand All @@ -149,7 +102,7 @@ struct CancellableBlockingWorkTests {

didRelease = true
release.signal()
#expect(await waitUntil { discarded.value })
try await waitUntil { discarded.value }
}

@Test("Work that completes before any cancel adopts the result")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation
@testable import TableProDatabase
@testable import TableProModels
import TableProTestSupport
import Testing

// MARK: - Mock Types
Expand Down Expand Up @@ -81,32 +82,6 @@ final class MockSecureStore: SecureStore, Sendable {

// MARK: - Synchronisation Helpers

actor Gate {
private var isOpen = false
private var hasEntered = false
private var blocked: [CheckedContinuation<Void, Never>] = []
private var observers: [CheckedContinuation<Void, Never>] = []

func enter() async {
hasEntered = true
for observer in observers { observer.resume() }
observers.removeAll()
guard !isOpen else { return }
await withCheckedContinuation { blocked.append($0) }
}

func open() {
isOpen = true
for waiter in blocked { waiter.resume() }
blocked.removeAll()
}

func waitUntilEntered() async {
guard !hasEntered else { return }
await withCheckedContinuation { observers.append($0) }
}
}

actor Flag {
private(set) var isRaised = false

Expand All @@ -128,17 +103,9 @@ actor OrderLog {
}
}

/// How an attempt run on its own task ended, for a test that has to be able to stop waiting for it.
///
/// A test must never end on `await task.value`: that ignores the awaiting task's own cancellation, so
/// `.timeLimit` cannot break it, and a task group never leaves scope while one of its children is
/// awaiting one. Work that never lands then hangs the whole run instead of failing the one test that
/// asked for it. Polling a recorded outcome is a wait the test can give up on, which is what turns
/// that hang back into a failure.
// Polled, never awaited: `await task.value` ignores the waiter's own cancellation, so `.timeLimit` cannot end it.
actor AttemptOutcome<Success: Sendable> {
private static var pollInterval: Duration { .milliseconds(10) }

private var result: Result<Success, any Error>?
private(set) var result: Result<Success, any Error>?

func record(_ work: @Sendable () async throws -> Success) async {
do {
Expand All @@ -148,20 +115,10 @@ actor AttemptOutcome<Success: Sendable> {
result = .failure(error)
}
}

func settled(within limit: Duration) async -> Result<Success, any Error>? {
let deadline = ContinuousClock.now + limit
while result == nil, ContinuousClock.now < deadline, !Task.isCancelled {
try? await Task.sleep(for: Self.pollInterval)
}
return result
}
}

// Bounded, so teardowns run one at a time fail the overlap count instead of hanging.
actor Barrier {
private static let pollInterval: UInt64 = 20_000_000
private static let maxPolls = 250

private let expected: Int
private var arrived = 0
private(set) var overlapped = 0
Expand All @@ -172,12 +129,7 @@ actor Barrier {

func arriveAndWait() async {
arrived += 1
var polls = 0
while arrived < expected, polls < Self.maxPolls {
try? await Task.sleep(nanoseconds: Self.pollInterval)
polls += 1
}
guard arrived >= expected else { return }
guard (try? await waitUntil(timeout: .seconds(5)) { self.arrived >= self.expected }) != nil else { return }
overlapped += 1
}
}
Expand Down
Loading
Loading