thefrontside / thefrontside/effection

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

Open
#1,190 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
856
Forks
39
Avg merge
2d 8h
Merged PRs (30d)
8

Description

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.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with lib/coroutine.ts and the step() logic described in the issue, then add the reproduction to test/scoped.test.ts. Run the existing async-finally tests, including “coroutine: uses 'return' for a single iteration when unwound” and “run: can suspend in yielded finally block.” Done means a halted delegated operation completes its async finally without running subsequent parent statements, while existing finally behavior remains passing.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
backend
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.