Skip to content

Halt does not propagate past a yield*-delegated sub-operation whose finally yields #1190

Description

@tarasvalstro

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions