Summary
When a task is halted while suspended, and the suspended point is inside a
yield*-delegated sub-operation whose finally performs asynchronous work
(any yield*), the coroutine does not stop after that finally completes — it
resumes normal execution past the halt, running subsequent statements and
even subsequent yield* operations. A synchronous finally behaves correctly.
Environment
- effection
4.0.3 (also reproduces on main / source tree)
- Reproduced on Node 22 and Deno
Minimal reproduction
import { run, sleep, suspend } from "effection";
function* halted() {
try {
yield* suspend();
} finally {
yield* sleep(0); // any async work in the finally
}
}
let leaked = false;
const task = run(function* () {
yield* halted(); // delegated sub-operation
leaked = true; // BUG: runs after the task was halted
});
await task.halt();
console.log("leaked:", leaked); // prints true; expected false
As a failing test in the style of test/scoped.test.ts:
it("does not run code after a halted operation whose finally yields", async () => {
let leaked = false;
function* halted() {
try {
yield* suspend();
} finally {
yield* sleep(0);
}
}
let task = run(function* () {
yield* halted();
leaked = true; // must not run: this is after a halted operation
});
await task.halt();
expect(leaked).toBe(false);
});
Observed behavior matrix
| code after the halted delegated op |
finally |
result |
| bare statement |
yield* sleep |
runs (BUG) |
| bare statement |
synchronous |
does not run (OK) |
inside try { } catch { } |
yield* sleep |
runs (BUG) |
inside try { } finally { } |
yield* sleep |
runs (BUG) |
statement + another yield* after |
yield* sleep |
both run — full resume (BUG) |
The last row is the most concerning: the coroutine fully resurrects and keeps
yielding after the halt as if it had never been halted.
The existing test can suspend in yielded finally block does not catch this
because it has no normal code after the delegation; it only asserts finally
ordering and that the task reports halted (which task.halt() reports
regardless of whether the body continued).
Root cause
lib/coroutine.ts, step():
} else if (data.unwinding && !data.critical) {
data.unwinding = false;
return iterator.return
? iterator.return()
: { done: true, value: undefined };
}
return iterator.next(resumeWith.value);
On halt, step() issues a single iterator.return() and clears unwinding.
When the finally reached by that return yields, the routine suspends; on
resume it falls through to iterator.next(...). For a same-frame finally this
correctly completes the return. But when the halted point is inside a
yield*-delegated child, resuming the child's finally with .next() lets the
child return and the parent's yield* deliver that value, so the parent
continues normally. V8 does not preserve the parent's abrupt-return intent
across the async-finally suspension when the generator is resumed with
.next().
Why the obvious fix is insufficient
Keeping unwinding set and re-issuing iterator.return() on resume regresses
async finally blocks that run code after a yield: re-issuing return() aborts
the remainder of the finally. With that change the suite fails 7 tests,
including:
coroutine: uses 'return' for a single iteration when unwound
run: can perform async operations in a finally block
run: can suspend in yielded finally block
So a fix must let a finally complete its own post-yield code via .next(),
while still propagating the return/halt through yield* to the parent once the
delegated sub-operation finishes returning.
Suggested fix direction
Preserve the "returning" state across an async finally's resumption so that,
once a delegated sub-operation completes its return, the parent is returned
rather than resumed — without aborting the finally's own post-yield code. This
likely requires tracking unwind/return state per delegation frame (rather than a
single unwinding boolean cleared after the first return()), or managing
operation composition explicitly instead of relying on native yield*
return-propagation.
Summary
When a task is halted while suspended, and the suspended point is inside a
yield*-delegated sub-operation whosefinallyperforms asynchronous work(any
yield*), the coroutine does not stop after thatfinallycompletes — itresumes normal execution past the halt, running subsequent statements and
even subsequent
yield*operations. A synchronousfinallybehaves correctly.Environment
4.0.3(also reproduces onmain/ source tree)Minimal reproduction
As a failing test in the style of
test/scoped.test.ts:Observed behavior matrix
yield* sleeptry { } catch { }yield* sleeptry { } finally { }yield* sleepyield*afteryield* sleepThe last row is the most concerning: the coroutine fully resurrects and keeps
yielding after the halt as if it had never been halted.
The existing test
can suspend in yielded finally blockdoes not catch thisbecause it has no normal code after the delegation; it only asserts
finallyordering and that the task reports
halted(whichtask.halt()reportsregardless of whether the body continued).
Root cause
lib/coroutine.ts,step():On halt,
step()issues a singleiterator.return()and clearsunwinding.When the
finallyreached by that return yields, the routine suspends; onresume it falls through to
iterator.next(...). For a same-framefinallythiscorrectly completes the return. But when the halted point is inside a
yield*-delegated child, resuming the child'sfinallywith.next()lets thechild return and the parent's
yield*deliver that value, so the parentcontinues normally. V8 does not preserve the parent's abrupt-return intent
across the async-
finallysuspension when the generator is resumed with.next().Why the obvious fix is insufficient
Keeping
unwindingset and re-issuingiterator.return()on resume regressesasync
finallyblocks that run code after a yield: re-issuingreturn()abortsthe remainder of the
finally. With that change the suite fails 7 tests,including:
coroutine: uses 'return' for a single iteration when unwoundrun: can perform async operations in a finally blockrun: can suspend in yielded finally blockSo a fix must let a
finallycomplete its own post-yield code via.next(),while still propagating the return/halt through
yield*to the parent once thedelegated sub-operation finishes returning.
Suggested fix direction
Preserve the "returning" state across an async
finally's resumption so that,once a delegated sub-operation completes its return, the parent is returned
rather than resumed — without aborting the
finally's own post-yield code. Thislikely requires tracking unwind/return state per delegation frame (rather than a
single
unwindingboolean cleared after the firstreturn()), or managingoperation composition explicitly instead of relying on native
yield*return-propagation.