Skip to content

🐛 Ensure Task.halt() does not resolve before resource cleanup finishes#1155

Open
cowboyd wants to merge 1 commit intov4from
fix/halt-cleanup-settling
Open

🐛 Ensure Task.halt() does not resolve before resource cleanup finishes#1155
cowboyd wants to merge 1 commit intov4from
fix/halt-cleanup-settling

Conversation

@cowboyd
Copy link
Copy Markdown
Member

@cowboyd cowboyd commented Apr 16, 2026

Motivation

Task.halt() can resolve while async resource cleanup is still running. Code that uses await task.halt() as a join point can proceed while resource finalizers are still suspended, producing leaked work and use-after-close races.

Fixes #1153

Approach

Add a settling flag to Delimiter that encapsulate sets when entering its finally block (before group.halt()). While settling, exit() skips the .return() call since the operation is already winding down naturally. close() now also waits for the delimiter future when the outcome is set but not yet finalized.

The flag is reset after group.halt() completes so that inner encapsulate calls (e.g. callcc) don't permanently poison the parent delimiter.

Possible Drawbacks or Risks

This is a spike — the settling flag adds a new state bit to Delimiter and a coupling between encapsulate and Delimiter via DelimiterContext. A future refactor could internalize this into the Delimiter itself.

TODOs and Open Questions

  • Evaluate whether settling should live inside the Delimiter's own [Symbol.iterator] rather than being set externally by encapsulate

When a task's operation completes and encapsulate enters its finally
block to halt child tasks, a concurrent task.halt() call could issue
a .return() that cuts across the in-progress cleanup, causing halt()
to resolve while resource finalizers are still running.

Add a `settling` flag to Delimiter that encapsulate sets when entering
cleanup. While settling, exit() skips the .return() call—the operation
is already winding down naturally. close() now also waits for the
delimiter future when the outcome is set but not yet finalized.

Fixes #1153
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 16, 2026

Open in StackBlitz

npm i https://pkg.pr.new/effection@1155

commit: d1f1c71

@cowboyd cowboyd changed the title 🐛 Fix Task.halt() resolving before resource cleanup finishes 🐛 Ensure Task.halt() does not resolve before resource cleanup finishes Apr 16, 2026
@cowboyd cowboyd requested a review from jbolda April 21, 2026 14:16
@cowboyd
Copy link
Copy Markdown
Member Author

cowboyd commented Apr 22, 2026

This PR fixes task.halt() not waiting for resource cleanup, but it introduces a regression: race() hangs when one arm contains an operation that uses resource(), even when that arm completes normally.

Minimal repro (only uses race, resource, withResolvers):

import { race, resource, withResolvers } from "effection";

yield* race([
  withResolvers<void>().operation,  // never resolves
  (function*() {
    yield* resource<number>(function*(provide) {
      yield* provide(42);
    });
  })(),
]);

console.log("race returned");  // never prints with this PR applied

On alpha.7 without the patch this returns immediately. With the patch applied it hangs indefinitely.

Likely cause: the new branch added to Delimiter.close():

else if (!this.finalized) {
  yield* this.close();
}

When race halts the winning candidate task during its post-winner shutdown pass, the candidate's outcome exists (its generator returned normally) but the delimiter isn't finalized yet because the resource is still live in scope. The new branch awaits this.future, but nothing drives that future to resolve — exit() is now gated by !this.settling, which together with the awaiting close produces a deadlock.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Task.halt() can resolve before resource cleanup finishes

1 participant