| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217 |
- import Foundation
- import Testing
- @testable import Trio
- @Suite("Nightscout Upload Serializer Tests") struct NightscoutUploadSerializerTests {
- /// Records whether an operation is currently in flight and flags any overlap.
- private actor OverlapDetector {
- private var inFlight = false
- private(set) var overlapDetected = false
- private(set) var completedRuns = 0
- func enter() {
- if inFlight { overlapDetected = true }
- inFlight = true
- }
- func exit() {
- inFlight = false
- completedRuns += 1
- }
- }
- /// Ordered log of named events across concurrent tasks, with a run counter.
- private actor EventLog {
- private(set) var events: [String] = []
- private var runNumber = 0
- func append(_ event: String) { events.append(event) }
- func beginRun() -> Int {
- runNumber += 1
- return runNumber
- }
- }
- /// One-shot gate that suspends waiters until opened.
- private actor Gate {
- private var isOpen = false
- private var waiters: [CheckedContinuation<Void, Never>] = []
- func open() {
- isOpen = true
- for waiter in waiters { waiter.resume() }
- waiters.removeAll()
- }
- func wait() async {
- if isOpen { return }
- await withCheckedContinuation { waiters.append($0) }
- }
- }
- /// Thrown when `waitUntil` gives up on its condition.
- private struct TimeoutError: Error {}
- /// Polls until `condition` is true, throwing after ~2 seconds so a broken
- /// serializer fails the test at the wait site instead of hanging it.
- private func waitUntil(_ condition: @escaping @Sendable() async -> Bool) async throws {
- for _ in 0 ..< 400 {
- if await condition() { return }
- try await Task.sleep(nanoseconds: 5_000_000)
- }
- throw TimeoutError()
- }
- @Test("Concurrent runs of the same pipeline never overlap") func noOverlapWithinPipeline() async {
- let detector = OverlapDetector()
- let serializer = NightscoutUploadSerializer { _ in
- await detector.enter()
- try? await Task.sleep(nanoseconds: 20_000_000)
- await detector.exit()
- }
- await withTaskGroup(of: Void.self) { group in
- for _ in 0 ..< 10 {
- group.addTask { await serializer.run(.overrides) }
- }
- }
- #expect(await detector.overlapDetected == false, "No two runs of the same pipeline may be in flight at once")
- #expect(await detector.completedRuns >= 1, "At least one run must execute")
- }
- @Test("A run requested mid-flight executes only after the current one finishes") func queuedRunWaitsForCurrent() async throws {
- let log = EventLog()
- let gate = Gate()
- let serializer = NightscoutUploadSerializer { _ in
- let n = await log.beginRun()
- await log.append("start-\(n)")
- if n == 1 { await gate.wait() }
- await log.append("end-\(n)")
- }
- await serializer.request(.overrides)
- try await waitUntil { await log.events.contains("start-1") }
- await serializer.request(.overrides)
- await gate.open()
- try await waitUntil { await log.events.contains("end-2") }
- #expect(await log.events == ["start-1", "end-1", "start-2", "end-2"])
- }
- @Test("Requests made while a run is in flight coalesce into a single follow-up run") func requestsCoalesceIntoOneFollowUp(
- ) async throws {
- let log = EventLog()
- let gate = Gate()
- let serializer = NightscoutUploadSerializer { _ in
- let n = await log.beginRun()
- await log.append("run-\(n)")
- if n == 1 { await gate.wait() }
- }
- await serializer.request(.glucose)
- try await waitUntil { await log.events.contains("run-1") }
- // All five requests arrive while the first run is blocked, so they must
- // coalesce into exactly one follow-up run.
- for _ in 0 ..< 5 {
- await serializer.request(.glucose)
- }
- await gate.open()
- try await waitUntil { await log.events.contains("run-2") }
- // Grace period: any extra (incorrect) follow-up runs would surface here.
- try await Task.sleep(nanoseconds: 100_000_000)
- #expect(await log.events == ["run-1", "run-2"], "Coalesced requests must produce exactly one follow-up run")
- }
- @Test("run returns only after its own operation has completed") func runAwaitsOwnOperation() async {
- let log = EventLog()
- let serializer = NightscoutUploadSerializer { _ in
- let n = await log.beginRun()
- await log.append("run-\(n)")
- }
- await serializer.run(.carbs)
- #expect(await log.events == ["run-1"])
- await serializer.run(.carbs)
- #expect(await log.events == ["run-1", "run-2"])
- }
- /// Holds the serializer so an upload operation can call back into it.
- private actor SerializerBox {
- private var serializer: NightscoutUploadSerializer?
- func set(_ serializer: NightscoutUploadSerializer) { self.serializer = serializer }
- func get() -> NightscoutUploadSerializer? { serializer }
- }
- @Test(
- "A re-entrant run from inside its own operation degrades to a follow-up instead of deadlocking"
- ) func reentrantRunDoesNotDeadlock() async throws {
- let log = EventLog()
- let reentrantLog = EventLog()
- let box = SerializerBox()
- let serializer = NightscoutUploadSerializer(
- uploadOperation: { _ in
- let n = await log.beginRun()
- await log.append("run-\(n)")
- if n == 1 {
- // Forbidden re-entrant call; the guard must turn it into a queued follow-up.
- await box.get()?.run(.overrides)
- }
- },
- onReentrantRun: { pipeline in
- Task { await reentrantLog.append("reentrant-\(pipeline)") }
- }
- )
- await box.set(serializer)
- await serializer.request(.overrides)
- try await waitUntil { await log.events.contains("run-2") }
- try await waitUntil { await reentrantLog.events.isEmpty == false }
- #expect(await log.events == ["run-1", "run-2"])
- #expect(await reentrantLog.events == ["reentrant-overrides"])
- }
- @Test("Different pipelines run independently of each other") func pipelinesAreIndependent() async throws {
- let log = EventLog()
- let gate = Gate()
- let serializer = NightscoutUploadSerializer { pipeline in
- if pipeline == .overrides {
- await log.append("overrides-start")
- await gate.wait()
- await log.append("overrides-end")
- } else {
- await log.append("tempTargets-done")
- }
- }
- await serializer.request(.overrides)
- try await waitUntil { await log.events.contains("overrides-start") }
- // Safety valve: if pipelines were wrongly serialized against each other,
- // the tempTargets run below would be stuck behind the gated overrides run.
- // Opening the gate after a delay lets the test fail on ordering instead of hanging.
- let safetyValve = Task {
- try? await Task.sleep(nanoseconds: 2_000_000_000)
- await gate.open()
- }
- await serializer.run(.tempTargets)
- await gate.open()
- try await waitUntil { await log.events.contains("overrides-end") }
- safetyValve.cancel()
- #expect(await log.events == ["overrides-start", "tempTargets-done", "overrides-end"])
- }
- }
|