Skip to content

Commit b550ce2

Browse files
committed
[kyo-kernel][kyo-core] tests for nested-computation resumption and fatal-error handling
PendingTest: map/flatMap/andThen must apply their continuation when the resumption over a nested value is deferred at a denied safepoint, so the inner effect stays handled. IOTaskTest: a computation that throws a fatal error on a scheduler fiber must still run its finalizers, so a promise a finalizer completes is not left pending. IOPromiseTest: a completion callback that throws must not abort the flush and leave the promise's other waiters unnotified.
1 parent 4125b2e commit b550ce2

3 files changed

Lines changed: 97 additions & 0 deletions

File tree

kyo-core/shared/src/test/scala/kyo/scheduler/IOPromiseTest.scala

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1248,4 +1248,24 @@ class IOPromiseTest extends kyo.test.Test[Any]:
12481248
}
12491249
}
12501250

1251+
"flush" - {
1252+
"a fatal error in one completion callback still runs the others" in {
1253+
// When a promise completes, its registered callbacks run in a single flush loop. A callback that throws
1254+
// must not abort that loop and leave the promise's other waiters unnotified. InterruptedException is a
1255+
// portable throwable that scala.util.control.NonFatal classifies as fatal; placing it between two
1256+
// counting callbacks means an aborted flush would skip one of them regardless of order.
1257+
var ran = 0
1258+
val p = new IOPromise[Nothing, Int]()
1259+
p.onComplete(_ => ran += 1)
1260+
p.onComplete(_ => throw new InterruptedException("fatal error"))
1261+
p.onComplete(_ => ran += 1)
1262+
try p.completeDiscard(Result.succeed(1))
1263+
catch case _: Throwable => () // contain any throwable that escapes the flush so the assertion below runs
1264+
assert(
1265+
ran == 2,
1266+
s"a fatal callback aborted the flush and orphaned the other waiters; only $ran of 2 non-fatal callbacks ran"
1267+
)
1268+
}
1269+
}
1270+
12511271
end IOPromiseTest

kyo-core/shared/src/test/scala/kyo/scheduler/IOTaskTest.scala

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,30 @@ class IOTaskTest extends kyo.test.Test[Any]:
109109

110110
}
111111

112+
"fatal error in a guarded body" - {
113+
// A computation on a scheduler fiber throws a fatal error. IOTask completes the fiber's own promise with a
114+
// Panic, but must still run the computation's finalizers; here a finalizer completes `probe`, standing in
115+
// for a promise awaited elsewhere. If finalizers are skipped on the fatal path, `probe` is never completed
116+
// and the timed await below expires. LinkageError is used because scala.util.control.NonFatal (which the
117+
// scheduler gates on) classifies it as fatal. JVM-only: it relies on worker-thread semantics (one worker
118+
// taking the fatal while the timeout fires on another), which the single-worker Native and single-threaded
119+
// JS runtimes do not provide.
120+
"runs the ensure finalizer even though the fatal aborts the fiber".onlyJvm in {
121+
for
122+
probe <- Promise.init[Unit, Any]
123+
_ <- Fiber.initUnscoped {
124+
Sync.ensure { probe.completeDiscard(Result.succeed(())) } {
125+
Sync.defer[Unit, Any](throw new LinkageError("fatal error"))
126+
}
127+
}
128+
// Await only `probe`, never the fatal fiber's own result: awaiting a fatally-aborted fiber re-raises
129+
// the fatal here. Bounded so a missing completion fails fast rather than blocking indefinitely.
130+
finished <- Abort.run[Any](Async.timeout(5.seconds)(probe.get))
131+
yield assert(
132+
finished.isSuccess,
133+
"the Sync.ensure finalizer did not run when the guarded body threw a fatal error; the awaited promise was never completed"
134+
)
135+
}
136+
}
137+
112138
end IOTaskTest

kyo-kernel/shared/src/test/scala/kyo/kernel/PendingTest.scala

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,23 @@ class PendingTest extends kyo.test.Test[Any]:
351351
ContextEffect.handle(Tag[TestEffect3], value)(v)
352352
end TestEffect3
353353

354+
// A fresh interceptor per call (it is stateful) that denies entry into the second pending computation: the
355+
// resumption over a nested `A < S` value, where map/flatMap/andThen must re-apply their continuation.
356+
def denyNestedResumption: Safepoint.Interceptor =
357+
new Safepoint.Interceptor:
358+
var seenNested = 0
359+
def addFinalizer(f: Maybe[Result.Error[Any]] => Unit): Unit = ()
360+
def removeFinalizer(f: Maybe[Result.Error[Any]] => Unit): Unit = ()
361+
def enter(frame: Frame, value: Any): Boolean =
362+
value match
363+
case _: kyo.kernel.internal.Kyo[?, ?] =>
364+
seenNested += 1
365+
seenNested != 2
366+
case _ => true
367+
368+
def nestedScenario: Int < TestEffect2 < TestEffect1 =
369+
Kyo.lift(TestEffect1(10)).map(_.map(s => Kyo.lift(TestEffect2(s))))
370+
354371
"basic nesting operations" in {
355372
val nested: String < TestEffect1 < Any = Kyo.lift(TestEffect1(42))
356373
assert(TestEffect1.run(nested.flatten).eval == "Effect1:42")
@@ -370,6 +387,40 @@ class PendingTest extends kyo.test.Test[Any]:
370387
assert(result2.eval == "Effect1:10".length + 10)
371388
}
372389

390+
// When the safepoint denies entry (as it does under fiber preemption), map/flatMap/andThen defer the
391+
// resumption over a nested `A < S` value; each must still apply its continuation so the inner effect stays
392+
// handled and does not leak past its handler. `denyNestedResumption` forces that deferral deterministically.
393+
// One leaf per operation.
394+
395+
"multiple effects with the nested map deferred at a denied safepoint" in {
396+
assert(nestedScenario.map(_.handle(TestEffect2.run)).handle(TestEffect1.run).eval == "Effect1:10".length + 10)
397+
val deferred =
398+
Safepoint.immediate(denyNestedResumption) {
399+
nestedScenario.map(_.handle(TestEffect2.run)).handle(TestEffect1.run)
400+
}
401+
assert(deferred.eval == "Effect1:10".length + 10)
402+
}
403+
404+
"multiple effects with the nested flatMap deferred at a denied safepoint" in {
405+
assert(nestedScenario.flatMap(_.handle(TestEffect2.run)).handle(TestEffect1.run).eval == "Effect1:10".length + 10)
406+
val deferred =
407+
Safepoint.immediate(denyNestedResumption) {
408+
nestedScenario.flatMap(_.handle(TestEffect2.run)).handle(TestEffect1.run)
409+
}
410+
assert(deferred.eval == "Effect1:10".length + 10)
411+
}
412+
413+
"discarding a nested computation with andThen deferred at a denied safepoint" in {
414+
// andThen discards the inner `Int < TestEffect2`; the deferred resumption must drop the whole nested
415+
// value rather than re-evaluate it at the wrong nesting.
416+
assert(nestedScenario.andThen(99).handle(TestEffect1.run).eval == 99)
417+
val deferred =
418+
Safepoint.immediate(denyNestedResumption) {
419+
nestedScenario.andThen(99).handle(TestEffect1.run)
420+
}
421+
assert(deferred.eval == 99)
422+
}
423+
373424
"map" in {
374425
val nested: String < TestEffect1 < Any = Kyo.lift(TestEffect1(50))
375426

0 commit comments

Comments
 (0)