Skip to content

Commit cda8136

Browse files
committed
[kyo-kernel][kyo-core] correct nested-computation resumption and fatal-error handling
Pending: map/flatMap/andThen deferred the resumption over the unwrapped value, so on resume the loop took the bare-suspension branch and dropped its continuation, leaking the inner effect. Defer over the original value so the resume re-applies the continuation. IOTask: a fatal error re-thrown from eval skipped run's termination block, so the task's finalizers never ran. Run the finalizers and release the trace on the fatal path before re-propagating. IOPromise: eval's completion-callback catch was gated on NonFatal, so a fatal callback aborted the flush loop and left the other waiters unnotified. Catch Throwable and log. BytecodeTest: update the map anonfun size for the smaller Effect.defer thunk.
1 parent b550ce2 commit cda8136

4 files changed

Lines changed: 25 additions & 7 deletions

File tree

kyo-core/shared/src/main/scala/kyo/scheduler/IOPromise.scala

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import kyo.*
66
import kyo.Result.Error
77
import kyo.kernel.internal.Safepoint
88
import scala.annotation.tailrec
9-
import scala.util.control.NonFatal
109

1110
sealed private[kyo] trait IOPromiseBase[+E, +A]:
1211
self: IOPromise[E, A] =>
@@ -414,7 +413,11 @@ private[kyo] object IOPromise:
414413
private inline def eval[A](inline f: => Unit): Unit =
415414
try f
416415
catch
417-
case ex if NonFatal(ex) =>
416+
// Completion callbacks run in a single loop over all of a promise's waiters; a throwable escaping here
417+
// would abort that loop and leave the remaining waiters unnotified. A callback is an isolated
418+
// side-effecting notification, not the fiber's own computation, so contain and log every failure
419+
// (fatal included) rather than propagate.
420+
case ex =>
418421
given Frame = Frame.internal
419422
import AllowUnsafe.embrace.danger
420423
Log.live.unsafe.error("uncaught exception", ex)

kyo-core/shared/src/main/scala/kyo/scheduler/IOTask.scala

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,22 @@ sealed private[kyo] class IOTask[Ctx, E, A] private (
126126

127127
final def run(startMillis: Long, clock: InternalClock, deadline: Long): Task.Result =
128128
val safepoint = Safepoint.get
129-
val next = eval(startMillis, clock, deadline)(using safepoint)
129+
val next =
130+
try eval(startMillis, clock, deadline)(using safepoint)
131+
catch
132+
case ex =>
133+
// A fatal error unwinds eval before the normal termination path below runs. The task's promise
134+
// is already completed with a Panic, but its finalizers would be skipped, stranding whatever
135+
// resource or awaited promise they release. Run the finalizers and release the trace, then
136+
// re-propagate the fatal.
137+
if !finalizers.isEmpty then
138+
finalizers.run(pollError())
139+
finalizers = Finalizers.empty
140+
if trace ne null then
141+
safepoint.releaseTrace(trace)
142+
trace = null.asInstanceOf[Trace]
143+
curr = nullResult
144+
throw ex
130145
if !isPending() then
131146
// On an interrupt that lands mid-slice, `next` is the accurate remainder whose head is the
132147
// suspension eval stopped in front of (for example an Async.Join), while `curr` is the stale

kyo-kernel/jvm/src/test/scala/kyo/kernel/BytecodeTest.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class BytecodeTest extends kyo.test.Test[Any]:
3131

3232
"map" in {
3333
val map = methodBytecodeSize[TestMap]
34-
assert(map == Map("test" -> 26, "anonfun" -> 11, "mapLoop" -> 151))
34+
assert(map == Map("test" -> 26, "anonfun" -> 8, "mapLoop" -> 151))
3535
}
3636

3737
"handle" in {

kyo-kernel/shared/src/main/scala/kyo/kernel/Pending.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ object `<`:
7676
// protocol this reproduces. A future protocol change updates that method
7777
// AND this site.
7878
if !safepoint.enter(_frame, value) then
79-
Effect.defer(mapLoop(value))
79+
Effect.defer(mapLoop(v))
8080
else
8181
try f(value): B < S2
8282
finally safepoint.exit()
@@ -112,7 +112,7 @@ object `<`:
112112
// protocol this reproduces. A future protocol change updates that method
113113
// AND this site.
114114
if !safepoint.enter(_frame, value) then
115-
Effect.defer(flatMapLoop(value))
115+
Effect.defer(flatMapLoop(v))
116116
else
117117
try f(value): B < S2
118118
finally safepoint.exit()
@@ -145,7 +145,7 @@ object `<`:
145145
// protocol this reproduces. A future protocol change updates that method
146146
// AND this site.
147147
if !safepoint.enter(_frame, value) then
148-
Effect.defer(andThenLoop(value))
148+
Effect.defer(andThenLoop(v))
149149
else
150150
try f: B < S2
151151
finally safepoint.exit()

0 commit comments

Comments
 (0)