Skip to content

Deferred completion with an interrupt cause skips suspended waiters (waiter never resumes) #7365

Closed

Description

@kitlangton

What version of Effect is running?

4.0.0-beta.107 (code unchanged at current main; affects at least 4.0.0-beta.107 through current)

What steps can reproduce the bug?

Complete a Deferred that has two or more suspended waiters with an effect whose cause contains an interrupt, e.g. Deferred.done(deferred, Exit.failCause(Cause.interrupt())).

Deterministic reproduction:

import { Cause, Deferred, Effect, Exit, Fiber } from "effect"

const program = Effect.gen(function*() {
  const deferred = yield* Deferred.make<number>()
  const first = yield* Deferred.await(deferred).pipe(
    Effect.forkChild({ startImmediately: true })
  )
  const second = yield* Deferred.await(deferred).pipe(
    Effect.forkChild({ startImmediately: true })
  )

  yield* Deferred.done(deferred, Exit.failCause(Cause.interrupt()))

  console.log("first: ", String(yield* Fiber.await(first)))
  console.log("second:", String(yield* Fiber.await(second).pipe(Effect.timeoutOption(1000))))
})

Effect.runPromise(program)

What is the expected behavior?

Every suspended waiter settles with the completion's interrupt cause:

first:  Failure(Cause([Interrupt(undefined)]))
second: some(Failure(Cause([Interrupt(undefined)])))

What do you see instead?

The second waiter is never resumed and suspends forever (only the timeout unblocks the example):

first:  Failure(Cause([Interrupt(undefined)]))
second: none()

Without the timeout, any fiber joining the second waiter hangs indefinitely.

Additional information

Mechanism: doneUnsafe iterates self.resumes live while resuming waiters. Resuming a suspended waiter with an interrupt cause kills that waiter fiber synchronously inside resume(effect), and the dying waiter's Deferred.await cleanup splices its entry out of the shared resumes array mid-iteration. The next waiter shifts down into the just-visited index, the loop index moves past it, and it is never resumed.

The #7193 guard made the cleanup safe after completion (resumes === undefined), but preserved the splice when cleanup fires during the completion loop, which is exactly this window.

All completion paths (done, complete, completeWith, fail, die, interrupt, into, and direct doneUnsafe callers such as PubSub and ScopedCache) funnel through the same loop.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions