Skip to content

isPending defers superseded async-iterable flight teardown (stale iterator runs to completion) #3122

Closed

Description

@ryansolid

Summary

When a computation's async-iterable flight is superseded, the stale iterator is normally closed (it.return()) synchronously at recompute. Adding a single isPending read over the same source changes this: the superseded iterator is left running — it can land a value that flight identity then discards — and return() only fires after the superseding flight settles.

Iterator close is the cancellation hook for anything resource-shaped behind an async iterable (fibers, sockets, subscriptions), so the deferral means wasted in-flight work and late resource teardown. Found while wiring Effect fibers to memos (examples/effect): the superseded search fiber keeps running its retries to completion instead of being interrupted at supersede time.

Ablation

Same graph in all four cases; only the reader changes. Memo flips [] → 400ms async iterable per query write; supersede happens 100ms in.

Reader Close timing
plain latest(() => m()) effect immediate at supersede ✅
fresh-mounted reader (mimics <Show> flip) immediate ✅
createLoadingBoundary + latest immediate ✅
createLoadingBoundary + latest + isPending deferred until superseding flight settles ❌ (stale flight lands first)

Observed output for the failing case:

    1ms  open  a
  103ms  write 'ab' (supersedes 'a')
  103ms  open  ab
  402ms  land  a      <-- stale flight ran to completion (value discarded)
  505ms  land  ab
  505ms  close a      <-- return() only after 'ab' settled

Expected (and observed as soon as the isPending read is removed):

    1ms  open  a
  103ms  write 'ab' (supersedes 'a')
  103ms  open  ab
  103ms  close a      <-- eager close at supersede
  504ms  land  ab

In the full web-render structure (examples/effect typeahead) the deferral only manifests on the first flight after a region mounts; in this minimal graph it defers every supersede.

Repro

Checked in as examples/effect/repro-close-timing.mjs (runs against packages/signals/dist/dev.js, no external deps). Toggle the isPending line inside the boundary content to flip between eager and deferred close:

const view = createLoadingBoundary(
  () => {
    try {
      // Comment out this isPending read → closes become eager again.
      const pending = isPending(() => m());
      return { pending, value: latest(() => m()) };
    } catch {
      return undefined;
    }
  },
  () => "loading-fallback"
);

Run with node examples/effect/repro-close-timing.mjs.

Environment

  • next (2.0.0-rc.x), observed at 42f2e8d
  • pnpm 11.1.1, Node 26.4.0, macOS

Activity

  1. ryansolid commented on Aug 31, 2026

    @ryansolid
    MemberAuthor

    Fixed in fa13761 (ships in rc.5). Root cause: the iterator close was registered only as an owner cleanup, and a recompute whose previous-run disposal rides the zombie-deferred channel drains it at commitPendingNode — which a verdict-held write defers until the superseding flight settles. The isPending read is what upgraded the write into that held shape, hence the ablation.

    The fix keys cancellation to flight identity instead of cleanup timing: a _flightTeardown slot fires at recompute's _inFlight release, so a superseded stream closes at supersede in every reader shape (the owner cleanup stays as the death backstop; close is idempotent). repro-close-timing.mjs now shows close a at the supersede write in both scenarios, and the stale flight never lands. Regression tests pinned in packages/signals/tests/iterator-supersede-close.test.ts — notably the "plain reader" shape also deferred under test timing, so the eager cases in the ablation were the lucky paths, not the rule. Effect fibers behind memos now interrupt at supersede time.

  2. added 6 commits that reference this issue on Aug 31, 2026
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