Skip to content

Commit 92448b6

Browse files
committed
[kyo-ai] make streaming and completion deadline tests deterministic
LLMStreamTest ("a stalled stream fails typed at the configured timeout", "a slow consumer does not spend the streaming deadline") and LLMTest ("a client-side timeout halts fast", "the configured timeout bounds a completion call and its retries") set a short config.timeout and raced it against a real mock-server round-trip. On the slow single-threaded arm64 Native runner the local production alone can exceed the budget, so the deadline fires at the wrong point and the leaves fail intermittently. Drive the deadline on a controlled Clock (Clock.withTimeControl) and order the real I/O with latches: the deadline now fires on an explicit time advance rather than by racing wall-clock against production, so the leaves are deterministic on every platform. Test-only; no production changes.
1 parent c8e204f commit 92448b6

2 files changed

Lines changed: 125 additions & 55 deletions

File tree

kyo-ai/shared/src/test/scala/kyo/LLMStreamTest.scala

Lines changed: 65 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -40,53 +40,80 @@ class LLMStreamTest extends kyo.test.Test[Any]:
4040

4141
"a stalled stream fails typed at the configured timeout after delivering its fragments" in {
4242
TestCompletionServer.runStreaming { server =>
43-
// The provider emits part of the envelope and then stops producing without a terminator. The
44-
// fragments must REACH the consumer and the call must still end at its deadline, so the
45-
// assertion is arrived-then-failed: a bound covering only the response headers would fail
46-
// before anything was delivered, and a stream with no bound would never end at all.
47-
val config = serverConfig(server.baseUrl).timeout(300.millis)
48-
for
49-
seen <- AtomicRef.init(Chunk.empty[String])
50-
_ <- server.enqueueStreamStall(Chunk(argDelta("""{"resultValue":""""), argDelta("partial")))
51-
result <- Abort.run[AIException](
52-
LLM.run(config)(Scope.run(AI.stream[String].map(_.foreach(s => seen.updateAndGet(_.append(s)).unit))))
53-
)
54-
delivered <- seen.get
55-
yield
56-
assert(
57-
delivered.mkString == "partial",
58-
s"the emitted fragments must reach the consumer before the deadline: $delivered"
59-
)
60-
result match
61-
case Result.Failure(_: AICompletionTimeoutException) => succeed
62-
case other => fail(s"expected the streaming deadline to fire on a stalled stream, got: $other")
63-
end match
64-
end for
43+
// The provider emits part of the envelope then stops without a terminator. The fragments must REACH
44+
// the consumer and the call must still end at its deadline. Timing is made deterministic with a
45+
// controlled Clock: the deadline sleeps on that Clock, so it fires exactly when the test advances
46+
// virtual time, never by racing a real short timeout against the real SSE round-trip. The `arrived`
47+
// latch orders "fragments delivered" (real I/O) BEFORE "deadline fired" (a clock advance) causally,
48+
// so the leaf cannot flake on a slow runner where delivery alone exceeds a fixed millisecond budget.
49+
val config = serverConfig(server.baseUrl).timeout(10.seconds)
50+
Clock.withTimeControl { control =>
51+
for
52+
arrived <- Latch.init(1)
53+
seen <- AtomicRef.init(Chunk.empty[String])
54+
_ <- server.enqueueStreamStall(Chunk(argDelta("""{"resultValue":""""), argDelta("partial")))
55+
fiber <- Fiber.init {
56+
Abort.run[AIException] {
57+
LLM.run(config)(Scope.run(AI.stream[String].map(_.foreach { s =>
58+
seen.updateAndGet(_.append(s)).flatMap(cur => Kyo.when(cur.mkString == "partial")(arrived.release))
59+
})))
60+
}
61+
}
62+
_ <- arrived.await
63+
_ <- control.advance(config.timeout + 1.second, 500.millis)
64+
result <- fiber.get
65+
delivered <- seen.get
66+
yield
67+
assert(
68+
delivered.mkString == "partial",
69+
s"the emitted fragments must reach the consumer before the deadline: $delivered"
70+
)
71+
result match
72+
case Result.Failure(_: AICompletionTimeoutException) => succeed
73+
case other => fail(s"expected the streaming deadline to fire on a stalled stream, got: $other")
74+
end match
75+
end for
76+
}
6577
}
6678
}
6779

6880
"a slow consumer does not spend the streaming deadline" in {
6981
TestCompletionServer.runStreaming { server =>
70-
// The deadline is a budget on the provider's production, not on wall-clock: a consumer that
71-
// pauses between elements for longer than the timeout must still receive the WHOLE stream, so
72-
// the assertion is on the delivered value. Asserting only that the call completed would also
73-
// be satisfied by an empty stream, which proves nothing about the bound.
74-
val config = serverConfig(server.baseUrl).timeout(300.millis)
82+
// The deadline is a budget on the provider's PRODUCTION, not on the consumer's wall-clock. Production
83+
// buffers the whole (tiny) stream and completes independently of the consumer, so once it has, virtual
84+
// time can be pushed FAR past the deadline while the consumer is still blocked and the deadline must not
85+
// fire. The consumer's slowness is a latch, not a sleep; the `firstSeen` latch plus a short real settle
86+
// order "production done" before the advance, so the leaf is deterministic rather than racing a fixed
87+
// millisecond consumer delay against the deadline.
88+
val config = serverConfig(server.baseUrl).timeout(10.seconds)
7589
val expected = List(Answer("ok"), Answer("two"))
7690
val args = elementArgs(expected)
7791
val split = Chunk(args.substring(0, 12), args.substring(12, 24), args.substring(24))
78-
for
79-
seen <- AtomicRef.init(Chunk.empty[Answer])
80-
_ <- server.enqueueStream(split.map(argDelta))
81-
_ <- LLM.run(config)(
82-
Scope.run(AI.stream[Answer].map(_.foreach(a => Async.sleep(700.millis).andThen(seen.updateAndGet(_.append(a)).unit))))
92+
Clock.withTimeControl { control =>
93+
for
94+
firstSeen <- Latch.init(1)
95+
proceed <- Latch.init(1)
96+
seen <- AtomicRef.init(Chunk.empty[Answer])
97+
_ <- server.enqueueStream(split.map(argDelta))
98+
fiber <- Fiber.init {
99+
LLM.run(config)(Scope.run(AI.stream[Answer].map(_.foreach { a =>
100+
seen.updateAndGet(_.append(a)).flatMap(cur =>
101+
Kyo.when(cur.size == 1)(firstSeen.release).andThen(proceed.await)
102+
)
103+
})))
104+
}
105+
_ <- firstSeen.await
106+
_ <- control.advance(Duration.Zero, 300.millis)
107+
_ <- control.advance(config.timeout * 2)
108+
_ <- proceed.release
109+
_ <- fiber.get
110+
delivered <- seen.get
111+
yield assert(
112+
delivered == Chunk.from(expected),
113+
s"a consumer slower than the timeout must still receive the whole stream, got: $delivered"
83114
)
84-
delivered <- seen.get
85-
yield assert(
86-
delivered == Chunk.from(expected),
87-
s"a consumer slower than the timeout must still receive the whole stream, got: $delivered"
88-
)
89-
end for
115+
end for
116+
}
90117
}
91118
}
92119

kyo-ai/shared/src/test/scala/kyo/LLMTest.scala

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -563,40 +563,83 @@ class LLMTest extends kyo.test.Test[Any]:
563563
}
564564
}
565565

566+
/** Waits until the server has captured at least `n` requests, advancing only real wall-clock time (never virtual
567+
* time) under the controlled Clock, so a leaf can defer advancing past the deadline until the request is genuinely
568+
* in flight rather than racing a fixed millisecond budget for it to reach the server.
569+
*/
570+
private def awaitCaptured(server: TestCompletionServer, control: Clock.TimeControl, n: Int)(using Frame): Unit < Async =
571+
Loop(0) { i =>
572+
server.captured.flatMap { caps =>
573+
if caps.size >= n then Loop.done
574+
else if i >= 200 then Loop.done
575+
else control.advance(Duration.Zero, 20.millis).andThen(Loop.continue(i + 1))
576+
}
577+
}
578+
566579
"a client-side timeout halts fast as AICompletionTimeoutException" in {
567580
TestCompletionServer.run { server =>
568-
val config = serverConfig(server.baseUrl).timeout(100.millis).retrySchedule(Schedule.repeat(1))
569-
server.enqueueNeverRespond.andThen {
570-
Abort.run[AIException](LLM.run(config)(AI.gen[String])).map { result =>
571-
server.captured.map { caps =>
581+
// Deterministic timing: the deadline runs on a controlled Clock, so it fires exactly when the test advances
582+
// virtual time, never by racing a real short timeout against the real request reaching the server. The
583+
// request is sent over real I/O and confirmed captured BEFORE the advance, so "request in flight" and
584+
// "deadline fired" are causally ordered; the leaf cannot flake on a slow runner where the request alone
585+
// takes longer than a fixed millisecond budget to arrive.
586+
val config = serverConfig(server.baseUrl).timeout(10.seconds).retrySchedule(Schedule.repeat(1))
587+
Clock.withTimeControl { control =>
588+
server.enqueueNeverRespond.andThen {
589+
for
590+
fiber <- Fiber.init(Abort.run[AIException](LLM.run(config)(AI.gen[String])))
591+
_ <- awaitCaptured(server, control, 1)
592+
_ <- control.advance(config.timeout + 1.second, 500.millis)
593+
result <- fiber.get
594+
caps <- server.captured
595+
yield
572596
result match
573597
case Result.Failure(_: AICompletionTimeoutException) => ()
574598
case other => fail(s"expected AICompletionTimeoutException, got: $other")
575599
assert(caps.size == 1, s"a per-call timeout must halt without retry, expected 1 request, got: ${caps.size}")
576-
}
600+
end for
577601
}
578602
}
579603
}
580604
}
581605

582606
"the configured timeout bounds a completion call and its retries, not one attempt" in {
583607
TestCompletionServer.run { server =>
584-
// Every attempt fails transiently and is retried on a schedule whose backoff outlasts the
585-
// configured timeout. The deadline covers the retry clause, so the call surfaces the timeout;
586-
// a deadline that covered only one attempt would let the schedule run to exhaustion and
587-
// surface the throttle instead.
608+
// The deadline covers the whole call INCLUDING its retry backoffs, not one attempt. Made deterministic
609+
// with a controlled Clock rather than by racing a real deadline against a real schedule: the first retry
610+
// backoff is set LONGER than the deadline, so after one throttled attempt the call is waiting to retry
611+
// when virtual time is advanced past the deadline. A call-scoped deadline (armed once at call start)
612+
// fires during that backoff, so exactly one attempt was made; an attempt-scoped deadline would have been
613+
// cancelled with the completed attempt and never fire, leaving the call to retry to exhaustion. The one
614+
// attempt is confirmed over real I/O, and a short real settle lets its throttle response schedule the
615+
// backoff, before the advance; the advance itself carries the causal firing, so nothing races real time.
616+
val callTimeout = 5.seconds
588617
val config = serverConfig(server.baseUrl)
589-
.timeout(300.millis)
590-
.retrySchedule(Schedule.exponentialBackoff(initial = 200.millis, factor = 2, maxBackoff = 2.seconds).take(10))
618+
.timeout(callTimeout)
619+
.retrySchedule(Schedule.exponentialBackoff(initial = callTimeout * 2, factor = 2, maxBackoff = 1.minute).take(10))
591620
def throttle(remaining: Int): Unit < Async =
592621
if remaining == 0 then ()
593622
else server.enqueueStatus(429, """{"error":{"message":"rate limited"}}""").andThen(throttle(remaining - 1))
594-
throttle(12).andThen {
595-
Abort.run[AIException](LLM.run(config)(AI.gen[String])).map { result =>
596-
result match
597-
case Result.Failure(_: AICompletionTimeoutException) => succeed
598-
case other =>
599-
fail(s"expected the call deadline to fire while retries were still pending, got: $other")
623+
Clock.withTimeControl { control =>
624+
throttle(12).andThen {
625+
for
626+
fiber <- Fiber.init(Abort.run[AIException](LLM.run(config)(AI.gen[String])))
627+
_ <- awaitCaptured(server, control, 1)
628+
_ <- control.advance(Duration.Zero, 300.millis)
629+
_ <- control.advance(callTimeout + 1.second, 500.millis)
630+
result <- fiber.get
631+
caps <- server.captured
632+
yield
633+
result match
634+
case Result.Failure(_: AICompletionTimeoutException) => ()
635+
case other =>
636+
fail(s"expected the call deadline to fire while retries were still pending, got: $other")
637+
end match
638+
assert(
639+
caps.size == 1,
640+
s"the deadline must fire during the first retry's backoff, so exactly one attempt is made, got ${caps.size}"
641+
)
642+
end for
600643
}
601644
}
602645
}

0 commit comments

Comments
 (0)