thefrontside / thefrontside/effection

v4.1.0: task.halt() rejects with the task's own error when the halt lands during a successful cleanup

Open
#1,235 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's body fails and its cleanup then runs successfully, task.halt() rejects with the body's
error if the halt is requested while that cleanup is still in flight. The teardown did not fail.

This conflicts with the documented contract for Task.halt(), which says errors raised by it
"only represent problems that occured during the teardown", and that halt can succeed even if the task failed.

The same task failure, with the same successful cleanup, is reported differently depending only on when the halt
is requested, so a consumer cannot use halt() to tell "your teardown failed" from "your task failed".

Reproduction

// effection 4.1.0
import { run, spawn, scoped, ensure, until, sleep, suspend } from 'effection';

const deferred = () => { let resolve; const p = new Promise((r) => { resolve = r; }); return { promise: p, resolve }; };

async function probe(label, body, { awaitCleanupFirst, gated }) {
  const cleaning = deferred();
  const gate = deferred();
  let halt = 'resolved';
  await run(function* () {
    const task = yield* spawn(() => body(cleaning, gate));
    // SUBJECT: the body has already failed and its cleanup is running when the halt is asked for.
    // CONTROLS: the task is alive; its cleanup begins because of the halt.
    if (awaitCleanupFirst) yield* until(cleaning.promise); else yield* sleep(5);
    if (gated) setTimeout(() => gate.resolve(), 20);
    try { yield* task.halt(); } catch (e) { halt = `REJECTED: ${e.message}`; }
    yield* sleep(10);
  });
  console.log(`${label.padEnd(46)} halt() -> ${halt}`);
}

// The body fails; its cleanup SUCCEEDS; the halt lands while that cleanup runs.
const subject = (cleaning, gate) => scoped(function* () {
  yield* ensure(function* () { cleaning.resolve(); yield* until(gate.promise); });
  throw new Error('BODY_FAILED');
});

// Cancelling a healthy task whose cleanup succeeds.
const healthy = (cleaning, gate) => scoped(function* () {
  yield* ensure(function* () { cleaning.resolve(); yield* until(gate.promise); });
  yield* suspend();
});

// A genuine teardown failure.
const badCleanup = (cleaning) => scoped(function* () {
  yield* ensure(function* () { cleaning.resolve(); throw new Error('CLEANUP_FAILED'); });
  yield* suspend();
});

await probe('SUBJECT  body fails, cleanup OK, halt mid-cleanup', subject,    { awaitCleanupFirst: true, gated: true });
await probe('CONTROL  healthy task, cleanup OK, cancelled',      healthy,    { gated: true });
await probe('CONTROL  genuine teardown failure',                 badCleanup, { gated: false });

Observed

effection 4.1.0, node v24.6.0

SUBJECT  body fails, cleanup OK, halt mid-cleanup  halt() -> REJECTED: BODY_FAILED
CONTROL  healthy task, cleanup OK, cancelled       halt() -> resolved
CONTROL  genuine teardown failure                  halt() -> REJECTED: CLEANUP_FAILED

Both controls behave as documented. Only the subject reports a non-teardown error, and its cleanup ran to completion.

Expected

halt() resolves in the subject case. The body's failure belongs to the task's own outcome, not to the halt.

Why it matters

We use halt() in a supervisor that replaces one long-running operation with another. A teardown failure means a
native resource was not released and nothing may run on it again, so it ends the session; an ordinary failure in the
operation is recoverable and the next operation should start. Because an ordinary failure racing a cancellation is
reported through halt(), the supervisor tears down a healthy session. There is no other signal to distinguish them:
the error arrives by the same route, in the same phase, and carries nothing that separates it.

Environment

  • effection 4.1.0
  • Node v24.6.0, macOS

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 at Task.halt() and trace how a task body error is propagated while ensure cleanup is in flight. Reproduce the subject and two controls from the issue with effection 4.1.0 and Node v24.6.0; done means the subject's halt resolves, healthy cancellation still resolves, and a genuine teardown failure still rejects.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, typescript
Domain
developer-experience
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.