@@ -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
0 commit comments