NightscoutUploadSerializerTests.swift 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. import Foundation
  2. import Testing
  3. @testable import Trio
  4. @Suite("Nightscout Upload Serializer Tests") struct NightscoutUploadSerializerTests {
  5. /// Records whether an operation is currently in flight and flags any overlap.
  6. private actor OverlapDetector {
  7. private var inFlight = false
  8. private(set) var overlapDetected = false
  9. private(set) var completedRuns = 0
  10. func enter() {
  11. if inFlight { overlapDetected = true }
  12. inFlight = true
  13. }
  14. func exit() {
  15. inFlight = false
  16. completedRuns += 1
  17. }
  18. }
  19. /// Ordered log of named events across concurrent tasks, with a run counter.
  20. private actor EventLog {
  21. private(set) var events: [String] = []
  22. private var runNumber = 0
  23. func append(_ event: String) { events.append(event) }
  24. func beginRun() -> Int {
  25. runNumber += 1
  26. return runNumber
  27. }
  28. }
  29. /// One-shot gate that suspends waiters until opened.
  30. private actor Gate {
  31. private var isOpen = false
  32. private var waiters: [CheckedContinuation<Void, Never>] = []
  33. func open() {
  34. isOpen = true
  35. for waiter in waiters { waiter.resume() }
  36. waiters.removeAll()
  37. }
  38. func wait() async {
  39. if isOpen { return }
  40. await withCheckedContinuation { waiters.append($0) }
  41. }
  42. }
  43. /// Thrown when `waitUntil` gives up on its condition.
  44. private struct TimeoutError: Error {}
  45. /// Polls until `condition` is true, throwing after ~2 seconds so a broken
  46. /// serializer fails the test at the wait site instead of hanging it.
  47. private func waitUntil(_ condition: @escaping @Sendable() async -> Bool) async throws {
  48. for _ in 0 ..< 400 {
  49. if await condition() { return }
  50. try await Task.sleep(nanoseconds: 5_000_000)
  51. }
  52. throw TimeoutError()
  53. }
  54. @Test("Concurrent runs of the same pipeline never overlap") func noOverlapWithinPipeline() async {
  55. let detector = OverlapDetector()
  56. let serializer = NightscoutUploadSerializer { _ in
  57. await detector.enter()
  58. try? await Task.sleep(nanoseconds: 20_000_000)
  59. await detector.exit()
  60. }
  61. await withTaskGroup(of: Void.self) { group in
  62. for _ in 0 ..< 10 {
  63. group.addTask { await serializer.run(.overrides) }
  64. }
  65. }
  66. #expect(await detector.overlapDetected == false, "No two runs of the same pipeline may be in flight at once")
  67. #expect(await detector.completedRuns >= 1, "At least one run must execute")
  68. }
  69. @Test("A run requested mid-flight executes only after the current one finishes") func queuedRunWaitsForCurrent() async throws {
  70. let log = EventLog()
  71. let gate = Gate()
  72. let serializer = NightscoutUploadSerializer { _ in
  73. let n = await log.beginRun()
  74. await log.append("start-\(n)")
  75. if n == 1 { await gate.wait() }
  76. await log.append("end-\(n)")
  77. }
  78. await serializer.request(.overrides)
  79. try await waitUntil { await log.events.contains("start-1") }
  80. await serializer.request(.overrides)
  81. await gate.open()
  82. try await waitUntil { await log.events.contains("end-2") }
  83. #expect(await log.events == ["start-1", "end-1", "start-2", "end-2"])
  84. }
  85. @Test("Requests made while a run is in flight coalesce into a single follow-up run") func requestsCoalesceIntoOneFollowUp(
  86. ) async throws {
  87. let log = EventLog()
  88. let gate = Gate()
  89. let serializer = NightscoutUploadSerializer { _ in
  90. let n = await log.beginRun()
  91. await log.append("run-\(n)")
  92. if n == 1 { await gate.wait() }
  93. }
  94. await serializer.request(.glucose)
  95. try await waitUntil { await log.events.contains("run-1") }
  96. // All five requests arrive while the first run is blocked, so they must
  97. // coalesce into exactly one follow-up run.
  98. for _ in 0 ..< 5 {
  99. await serializer.request(.glucose)
  100. }
  101. await gate.open()
  102. try await waitUntil { await log.events.contains("run-2") }
  103. // Grace period: any extra (incorrect) follow-up runs would surface here.
  104. try await Task.sleep(nanoseconds: 100_000_000)
  105. #expect(await log.events == ["run-1", "run-2"], "Coalesced requests must produce exactly one follow-up run")
  106. }
  107. @Test("run returns only after its own operation has completed") func runAwaitsOwnOperation() async {
  108. let log = EventLog()
  109. let serializer = NightscoutUploadSerializer { _ in
  110. let n = await log.beginRun()
  111. await log.append("run-\(n)")
  112. }
  113. await serializer.run(.carbs)
  114. #expect(await log.events == ["run-1"])
  115. await serializer.run(.carbs)
  116. #expect(await log.events == ["run-1", "run-2"])
  117. }
  118. /// Holds the serializer so an upload operation can call back into it.
  119. private actor SerializerBox {
  120. private var serializer: NightscoutUploadSerializer?
  121. func set(_ serializer: NightscoutUploadSerializer) { self.serializer = serializer }
  122. func get() -> NightscoutUploadSerializer? { serializer }
  123. }
  124. @Test(
  125. "A re-entrant run from inside its own operation degrades to a follow-up instead of deadlocking"
  126. ) func reentrantRunDoesNotDeadlock() async throws {
  127. let log = EventLog()
  128. let reentrantLog = EventLog()
  129. let box = SerializerBox()
  130. let serializer = NightscoutUploadSerializer(
  131. uploadOperation: { _ in
  132. let n = await log.beginRun()
  133. await log.append("run-\(n)")
  134. if n == 1 {
  135. // Forbidden re-entrant call; the guard must turn it into a queued follow-up.
  136. await box.get()?.run(.overrides)
  137. }
  138. },
  139. onReentrantRun: { pipeline in
  140. Task { await reentrantLog.append("reentrant-\(pipeline)") }
  141. }
  142. )
  143. await box.set(serializer)
  144. await serializer.request(.overrides)
  145. try await waitUntil { await log.events.contains("run-2") }
  146. try await waitUntil { await reentrantLog.events.isEmpty == false }
  147. #expect(await log.events == ["run-1", "run-2"])
  148. #expect(await reentrantLog.events == ["reentrant-overrides"])
  149. }
  150. @Test("Different pipelines run independently of each other") func pipelinesAreIndependent() async throws {
  151. let log = EventLog()
  152. let gate = Gate()
  153. let serializer = NightscoutUploadSerializer { pipeline in
  154. if pipeline == .overrides {
  155. await log.append("overrides-start")
  156. await gate.wait()
  157. await log.append("overrides-end")
  158. } else {
  159. await log.append("tempTargets-done")
  160. }
  161. }
  162. await serializer.request(.overrides)
  163. try await waitUntil { await log.events.contains("overrides-start") }
  164. // Safety valve: if pipelines were wrongly serialized against each other,
  165. // the tempTargets run below would be stuck behind the gated overrides run.
  166. // Opening the gate after a delay lets the test fail on ordering instead of hanging.
  167. let safetyValve = Task {
  168. try? await Task.sleep(nanoseconds: 2_000_000_000)
  169. await gate.open()
  170. }
  171. await serializer.run(.tempTargets)
  172. await gate.open()
  173. try await waitUntil { await log.events.contains("overrides-end") }
  174. safetyValve.cancel()
  175. #expect(await log.events == ["overrides-start", "tempTargets-done", "overrides-end"])
  176. }
  177. }