From 3924b261c437a76a8501a97804a82a59f2ed7ab3 Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Sat, 6 Jun 2026 10:46:41 -0400 Subject: [PATCH] Add Clock, ContinuousClock, SuspendingClock, and InstantProtocol --- Sources/SkipLib/Duration.swift | 158 ++++++++++++++++++ Sources/SkipLib/Skip/Clock.kt | 192 ++++++++++++++++++++++ Sources/SkipLib/Skip/Duration.kt | 4 + Tests/SkipLibTests/ClockTests.swift | 124 ++++++++++++++ Tests/SkipLibTests/ConcurrencyTests.swift | 59 +++---- 5 files changed, 498 insertions(+), 39 deletions(-) create mode 100644 Sources/SkipLib/Skip/Clock.kt create mode 100644 Tests/SkipLibTests/ClockTests.swift diff --git a/Sources/SkipLib/Duration.swift b/Sources/SkipLib/Duration.swift index 934a558..ec39a13 100644 --- a/Sources/SkipLib/Duration.swift +++ b/Sources/SkipLib/Duration.swift @@ -16,6 +16,11 @@ public struct Duration: Hashable, Comparable, Sendable, CustomStringConvertible /// The attoseconds component of the duration (0 ..< 1_000_000_000_000_000_000). public let attoseconds: Int64 + /// The seconds and attoseconds components, matching Swift's `Duration.components`. + public var components: (seconds: Int64, attoseconds: Int64) { + fatalError() + } + /// Creates a duration from seconds and attoseconds components. public init(secondsComponent: Int64, attosecondsComponent: Int64) { fatalError() @@ -103,4 +108,157 @@ public struct Duration: Hashable, Comparable, Sendable, CustomStringConvertible } } +/// A type that defines a specific point in time for a given `Clock`. +public protocol InstantProtocol: Hashable, Comparable, Sendable { + associatedtype Duration + + func advanced(by duration: Duration) -> Self + func duration(to other: Self) -> Duration +} + +extension InstantProtocol { + public static func + (lhs: Self, rhs: Duration) -> Self { + fatalError() + } + + public static func - (lhs: Self, rhs: Duration) -> Self { + fatalError() + } + + public static func - (lhs: Self, rhs: Self) -> Duration { + fatalError() + } + + public static func += (lhs: inout Self, rhs: Duration) { + fatalError() + } + + public static func -= (lhs: inout Self, rhs: Duration) { + fatalError() + } +} + +/// A mechanism in which to measure time, and delay work until a given point in time. +public protocol Clock: Sendable { + associatedtype Duration + associatedtype Instant: InstantProtocol where Self.Instant.Duration == Self.Duration + + var now: Instant { get } + var minimumResolution: Duration { get } + + func sleep(until deadline: Instant, tolerance: Duration?) async throws +} + +extension Clock { + public func sleep(for duration: Duration) async throws { + fatalError() + } + + public func measure(_ work: () async throws -> Void) async rethrows -> Duration { + fatalError() + } +} + +/// A clock that measures time that always increments and does not stop +/// incrementing while the system is asleep. +public struct ContinuousClock: Clock, Sendable { + public struct Instant: InstantProtocol, Hashable, Comparable, Sendable, CustomStringConvertible { + public func advanced(by duration: Duration) -> Instant { + fatalError() + } + + public func duration(to other: Instant) -> Duration { + fatalError() + } + + public static func < (lhs: Instant, rhs: Instant) -> Bool { + fatalError() + } + + public var description: String { + fatalError() + } + + public static var now: Instant { + fatalError() + } + } + + public init() { + } + + public var now: Instant { + fatalError() + } + + public var minimumResolution: Duration { + fatalError() + } + + public func sleep(until deadline: Instant, tolerance: Duration? = nil) async throws { + fatalError() + } + + public static var now: Instant { + fatalError() + } +} + +extension Clock where Self == ContinuousClock { + public static var continuous: ContinuousClock { + fatalError() + } +} + +/// A clock that measures time that always increments, but stops incrementing +/// while the system is asleep. +public struct SuspendingClock: Clock, Sendable { + public struct Instant: InstantProtocol, Hashable, Comparable, Sendable, CustomStringConvertible { + public func advanced(by duration: Duration) -> Instant { + fatalError() + } + + public func duration(to other: Instant) -> Duration { + fatalError() + } + + public static func < (lhs: Instant, rhs: Instant) -> Bool { + fatalError() + } + + public var description: String { + fatalError() + } + + public static var now: Instant { + fatalError() + } + } + + public init() { + } + + public var now: Instant { + fatalError() + } + + public var minimumResolution: Duration { + fatalError() + } + + public func sleep(until deadline: Instant, tolerance: Duration? = nil) async throws { + fatalError() + } + + public static var now: Instant { + fatalError() + } +} + +extension Clock where Self == SuspendingClock { + public static var suspending: SuspendingClock { + fatalError() + } +} + #endif diff --git a/Sources/SkipLib/Skip/Clock.kt b/Sources/SkipLib/Skip/Clock.kt new file mode 100644 index 0000000..237d698 --- /dev/null +++ b/Sources/SkipLib/Skip/Clock.kt @@ -0,0 +1,192 @@ +// Copyright 2023–2026 Skip +// SPDX-License-Identifier: MPL-2.0 +package skip.lib + +import kotlin.time.TimeSource +import kotlin.time.DurationUnit +import kotlin.time.toDuration +import kotlin.time.Duration as KtDuration + +/** + * Kotlin representation of Swift's `InstantProtocol`. + * + * Concrete clock `Instant` types implement this marker interface and provide + * `advanced(by:)`, `duration(to:)`, and `Comparable` semantics directly. + */ +interface InstantProtocol + +/** + * Kotlin representation of Swift's `Clock` protocol. + * + * Implementations measure time and offer a `sleep(until:tolerance:)` primitive. + * `Instant` is the type returned by `now`; the associated duration is always + * `skip.lib.Duration`. + */ +interface Clock { + val now: I + val minimumResolution: Duration + + suspend fun sleep(until: I, tolerance: Duration? = null) + + suspend fun sleep(for_: Duration) { + Task.sleep(for_ = for_) + } + + suspend fun measure(work: suspend () -> Unit): Duration { + val start = now + work() + val end = now + return durationBetween(start, end) + } + + fun durationBetween(start: I, end: I): Duration +} + +/// Convert a `skip.lib.Duration` to a `kotlin.time.Duration`. +internal fun Duration.toKotlinDuration(): KtDuration = + toNanoseconds().toDuration(DurationUnit.NANOSECONDS) + +/// Convert a `kotlin.time.Duration` to a `skip.lib.Duration`. +internal fun KtDuration.toSkipDuration(): Duration = + Duration.nanoseconds(inWholeNanoseconds) + +/** + * Kotlin representation of Swift's `ContinuousClock`. + * + * Backed by `kotlin.time.TimeSource.Monotonic`, which on the JVM is + * implemented by `System.nanoTime()` and continues to advance while the host + * is idle. The Swift distinction between continuous and suspending clocks + * does not exist on Android; the two clock classes share an implementation + * but Swift code that names `ContinuousClock` explicitly continues to work. + */ +class ContinuousClock : Clock { + + /** + * A point in time on a `ContinuousClock`. + * + * Backed by a `kotlin.time.TimeSource.Monotonic.ValueTimeMark`. + */ + class Instant internal constructor(internal val mark: TimeSource.Monotonic.ValueTimeMark) : InstantProtocol, Comparable { + + fun advanced(by: Duration): Instant { + return Instant(mark + by.toKotlinDuration()) + } + + fun duration(to: Instant): Duration { + return (to.mark - mark).toSkipDuration() + } + + operator fun plus(duration: Duration): Instant = advanced(by = duration) + operator fun minus(duration: Duration): Instant = Instant(mark - duration.toKotlinDuration()) + operator fun minus(other: Instant): Duration = other.duration(to = this) + + override fun compareTo(other: Instant): Int = mark.compareTo(other.mark) + + override fun equals(other: Any?): Boolean { + val o = other as? Instant ?: return false + return mark == o.mark + } + + override fun hashCode(): Int = mark.hashCode() + override fun toString(): String = "ContinuousClock.Instant($mark)" + + companion object { + val now: Instant + get() = Instant(TimeSource.Monotonic.markNow()) + } + } + + override val now: Instant + get() = Instant.now + + override val minimumResolution: Duration + get() = Duration.nanoseconds(1L) + + override suspend fun sleep(until: Instant, tolerance: Duration?) { + val remaining = -until.mark.elapsedNow() + if (remaining <= KtDuration.ZERO) return + Task.sleep(for_ = remaining.toSkipDuration()) + } + + override fun durationBetween(start: Instant, end: Instant): Duration { + return start.duration(to = end) + } + + companion object { + val now: Instant + get() = Instant.now + + // Mirror Swift's `Clock.continuous` shorthand. + val continuous: ContinuousClock = ContinuousClock() + } +} + +/** + * Kotlin representation of Swift's `SuspendingClock`. + * + * On Android there is no first-class distinction between a continuous and a + * suspending clock: both are backed by `kotlin.time.TimeSource.Monotonic`. + * The class exists so Swift code that names `SuspendingClock` transpiles + * cleanly. + */ +class SuspendingClock : Clock { + + /** + * A point in time on a `SuspendingClock`. + * + * Backed by a `kotlin.time.TimeSource.Monotonic.ValueTimeMark`. + */ + class Instant internal constructor(internal val mark: TimeSource.Monotonic.ValueTimeMark) : InstantProtocol, Comparable { + + fun advanced(by: Duration): Instant { + return Instant(mark + by.toKotlinDuration()) + } + + fun duration(to: Instant): Duration { + return (to.mark - mark).toSkipDuration() + } + + operator fun plus(duration: Duration): Instant = advanced(by = duration) + operator fun minus(duration: Duration): Instant = Instant(mark - duration.toKotlinDuration()) + operator fun minus(other: Instant): Duration = other.duration(to = this) + + override fun compareTo(other: Instant): Int = mark.compareTo(other.mark) + + override fun equals(other: Any?): Boolean { + val o = other as? Instant ?: return false + return mark == o.mark + } + + override fun hashCode(): Int = mark.hashCode() + override fun toString(): String = "SuspendingClock.Instant($mark)" + + companion object { + val now: Instant + get() = Instant(TimeSource.Monotonic.markNow()) + } + } + + override val now: Instant + get() = Instant.now + + override val minimumResolution: Duration + get() = Duration.nanoseconds(1L) + + override suspend fun sleep(until: Instant, tolerance: Duration?) { + val remaining = -until.mark.elapsedNow() + if (remaining <= KtDuration.ZERO) return + Task.sleep(for_ = remaining.toSkipDuration()) + } + + override fun durationBetween(start: Instant, end: Instant): Duration { + return start.duration(to = end) + } + + companion object { + val now: Instant + get() = Instant.now + + // Mirror Swift's `Clock.suspending` shorthand. + val suspending: SuspendingClock = SuspendingClock() + } +} diff --git a/Sources/SkipLib/Skip/Duration.kt b/Sources/SkipLib/Skip/Duration.kt index 1172c83..32e3c5b 100644 --- a/Sources/SkipLib/Skip/Duration.kt +++ b/Sources/SkipLib/Skip/Duration.kt @@ -76,6 +76,10 @@ data class Duration private constructor( } } + /** Seconds and attoseconds components, matching Swift's `Duration.components`. */ + val components: Tuple2 + get() = Tuple2(seconds, attoseconds) + /** Total duration in nanoseconds. */ fun toNanoseconds(): Long { return seconds * 1_000_000_000L + attoseconds / ATTOSECONDS_PER_NANOSECOND diff --git a/Tests/SkipLibTests/ClockTests.swift b/Tests/SkipLibTests/ClockTests.swift new file mode 100644 index 0000000..e47b00f --- /dev/null +++ b/Tests/SkipLibTests/ClockTests.swift @@ -0,0 +1,124 @@ +// Copyright 2023–2026 Skip +// SPDX-License-Identifier: MPL-2.0 +import Testing + +@Suite class ClockTests { + + // Convert a Duration to a Double seconds count for tolerance-based checks. + func toSecondsDouble(_ d: Duration) -> Double { + return Double(d.components.seconds) + Double(d.components.attoseconds) / 1.0e18 + } + + // MARK: - ContinuousClock + + @Test func continuousClockNowAdvances() async throws { + let clock = ContinuousClock() + let a = clock.now + try await Task.sleep(for: .milliseconds(20)) + let b = clock.now + #expect(a < b) + #expect(!(b < a)) + #expect(a != b) + } + + @Test func continuousClockInstantAdvancedBy() { + let clock = ContinuousClock() + let a = clock.now + let b = a.advanced(by: .seconds(1)) + let diff = a.duration(to: b) + // The advance is exactly 1 second within the precision of our representation. + #expect(toSecondsDouble(diff) >= 0.999) + #expect(toSecondsDouble(diff) <= 1.001) + #expect(a < b) + } + + @Test func continuousClockInstantDurationToReverseIsNegative() { + let clock = ContinuousClock() + let a = clock.now + let b = a.advanced(by: .milliseconds(500)) + let forward = a.duration(to: b) + let backward = b.duration(to: a) + #expect(toSecondsDouble(forward) > 0.0) + #expect(toSecondsDouble(backward) < 0.0) + } + + @Test func continuousClockMeasureSleep() async throws { + let clock = ContinuousClock() + let elapsed = try await clock.measure { + try await Task.sleep(for: .milliseconds(100)) + } + let elapsedSeconds = toSecondsDouble(elapsed) + #expect(elapsedSeconds >= 0.08) + #expect(elapsedSeconds < 5.0) + } + + @Test func continuousClockSleepUntil() async throws { + let clock = ContinuousClock() + let elapsed = try await clock.measure { + let deadline = clock.now.advanced(by: .milliseconds(100)) + try await clock.sleep(until: deadline, tolerance: nil) + } + let elapsedSeconds = toSecondsDouble(elapsed) + #expect(elapsedSeconds >= 0.08) + #expect(elapsedSeconds < 5.0) + } + + @Test func continuousClockSleepUntilInPastReturnsImmediately() async throws { + let clock = ContinuousClock() + let elapsed = try await clock.measure { + // Schedule a deadline 1 second ago — should return without sleeping. + let deadline = clock.now.advanced(by: .seconds(-1)) + try await clock.sleep(until: deadline, tolerance: nil) + } + #expect(toSecondsDouble(elapsed) < 0.5) + } + + @Test func continuousClockMinimumResolutionIsPositive() { + let clock = ContinuousClock() + let res = clock.minimumResolution + let resSeconds = toSecondsDouble(res) + #expect(resSeconds > 0.0) + #expect(resSeconds < 1.0) + } + + // MARK: - SuspendingClock + + @Test func suspendingClockNowAdvances() async throws { + let clock = SuspendingClock() + let a = clock.now + try await Task.sleep(for: .milliseconds(20)) + let b = clock.now + #expect(a < b) + } + + @Test func suspendingClockInstantAdvancedBy() { + let clock = SuspendingClock() + let a = clock.now + let b = a.advanced(by: .seconds(1)) + let diff = a.duration(to: b) + #expect(toSecondsDouble(diff) >= 0.999) + #expect(toSecondsDouble(diff) <= 1.001) + #expect(a < b) + } + + @Test func suspendingClockMeasureSleep() async throws { + let clock = SuspendingClock() + let elapsed = try await clock.measure { + try await Task.sleep(for: .milliseconds(100)) + } + let elapsedSeconds = toSecondsDouble(elapsed) + #expect(elapsedSeconds >= 0.08) + #expect(elapsedSeconds < 5.0) + } + + @Test func suspendingClockSleepUntil() async throws { + let clock = SuspendingClock() + let elapsed = try await clock.measure { + let deadline = clock.now.advanced(by: .milliseconds(100)) + try await clock.sleep(until: deadline, tolerance: nil) + } + let elapsedSeconds = toSecondsDouble(elapsed) + #expect(elapsedSeconds >= 0.08) + #expect(elapsedSeconds < 5.0) + } +} diff --git a/Tests/SkipLibTests/ConcurrencyTests.swift b/Tests/SkipLibTests/ConcurrencyTests.swift index a626237..c48a2e3 100644 --- a/Tests/SkipLibTests/ConcurrencyTests.swift +++ b/Tests/SkipLibTests/ConcurrencyTests.swift @@ -608,59 +608,40 @@ import Testing // MARK: - Duration Tests - // Helper to access Duration components cross-platform. - // On native Swift, Duration.seconds/attoseconds properties require macOS 15+, - // but Duration.components is available from macOS 13+. - // On Skip, the custom Duration type has direct seconds/attoseconds properties. - func secs(_ d: Duration) -> Int64 { - #if SKIP - return d.seconds - #else - return d.components.seconds - #endif - } - func attos(_ d: Duration) -> Int64 { - #if SKIP - return d.attoseconds - #else - return d.components.attoseconds - #endif - } - @Test func durationSeconds() { let d = Duration.seconds(3) - #expect(secs(d) == 3) - #expect(attos(d) == 0) + #expect(d.components.seconds == 3) + #expect(d.components.attoseconds == 0) } @Test func durationMilliseconds() { let d = Duration.milliseconds(1500) - #expect(secs(d) == 1) - #expect(attos(d) == 500_000_000_000_000_000) + #expect(d.components.seconds == 1) + #expect(d.components.attoseconds == 500_000_000_000_000_000) } @Test func durationMicroseconds() { let d = Duration.microseconds(2_500_000) - #expect(secs(d) == 2) - #expect(attos(d) == 500_000_000_000_000_000) + #expect(d.components.seconds == 2) + #expect(d.components.attoseconds == 500_000_000_000_000_000) } @Test func durationNanoseconds() { let d = Duration.nanoseconds(1_500_000_000) - #expect(secs(d) == 1) - #expect(attos(d) == 500_000_000_000_000_000) + #expect(d.components.seconds == 1) + #expect(d.components.attoseconds == 500_000_000_000_000_000) } @Test func durationSecondsDouble() { let d = Duration.seconds(2.5) - #expect(secs(d) == 2) - #expect(attos(d) == 500_000_000_000_000_000) + #expect(d.components.seconds == 2) + #expect(d.components.attoseconds == 500_000_000_000_000_000) } @Test func durationZero() { let d = Duration.zero - #expect(secs(d) == 0) - #expect(attos(d) == 0) + #expect(d.components.seconds == 0) + #expect(d.components.attoseconds == 0) } @Test func durationComparison() { @@ -677,30 +658,30 @@ import Testing let a = Duration.seconds(1) let b = Duration.milliseconds(500) let sum = a + b - #expect(secs(sum) == 1) - #expect(attos(sum) == 500_000_000_000_000_000) + #expect(sum.components.seconds == 1) + #expect(sum.components.attoseconds == 500_000_000_000_000_000) } @Test func durationSubtraction() { let a = Duration.seconds(2) let b = Duration.milliseconds(500) let diff = a - b - #expect(secs(diff) == 1) - #expect(attos(diff) == 500_000_000_000_000_000) + #expect(diff.components.seconds == 1) + #expect(diff.components.attoseconds == 500_000_000_000_000_000) } @Test func durationMultiplication() { let d = Duration.seconds(2) let result = d * 3 - #expect(secs(result) == 6) - #expect(attos(result) == 0) + #expect(result.components.seconds == 6) + #expect(result.components.attoseconds == 0) } @Test func durationDivision() { let d = Duration.seconds(6) let result = d / 3 - #expect(secs(result) == 2) - #expect(attos(result) == 0) + #expect(result.components.seconds == 2) + #expect(result.components.attoseconds == 0) } @Test func taskSleepForDuration() async throws {