Skip to content

until() inside an action deadlocks when the confirming frame was staged before until() was called (regression from #3451) #3482

Description

@brenelz

Describe the bug

yield until(predicate) inside an action() never resolves when the predicate reads the same createOptimisticStore a live stream feeds and the confirming frame landed before until() was called. With a timeout it rejects with TimeoutError; without one the action hangs forever.

This is the single-primitive shape the until() docstring recommends (optimistic row + socket echo), and it works when the frame lands after until() subscribed. The ordering that breaks is the natural one for a server that broadcasts to subscribers before it answers the mutation.

Regression. Bisected to d80cd1f "fix(signals): nodes created mainline during a hold are born held (A29 creation-time form) (#3451)". Its parent 61a114c passes, published @solidjs/signals@2.0.0-rc.8 passes, current next (37ea885) fails.

Mechanism. The action's transaction holds the staged frame under its optimism. until() creates its predicate effect from mainline code (after the await), so with #3451 that effect is born held: its synchronous first run is skipped and it is added to the transaction's _gatedSubs to be replayed at commit. But the commit is the action's settle, which is exactly what the until() promise is holding open. Deadlock.

CONFIG_DIRECT_COMMIT exists for this case (its comment: "a staged value with an immediate apply ... deadlocks the hold (until)"), but recompute's direct-commit branch is gated on bornHeld === null, so the born-held path pre-empts it.

Your Example Website or App

Vitest test against packages/signals (below). No app or extra dependencies needed. I hit it in a real app: a seat-reservation demo where a live() server function streams the seat map into an optimistic store and a reserve action does await reserveSeat(); yield until(() => data.reservations.some(r => r.id === saved.id)).

Steps to Reproduce the Bug or Issue

  1. Check out next.
  2. Save the test below as packages/signals/tests/until-held-frame-before-call.test.ts.
  3. cd packages/signals && pnpm vitest run tests/until-held-frame-before-call.test.ts
  4. It fails with expected TimeoutError ... to not be an instance of TimeoutError. Push the confirming frame after answer(...) instead and it passes.
import { expect, test } from "vitest";
import { action, createOptimisticStore, createRenderEffect, createRoot, flush, TimeoutError, until } from "../src/index.js";

type Row = { id: string; status: "pending" | "confirmed" };
type Snapshot = { rows: Row[] };

const settle = async (n = 3) => {
  for (let i = 0; i < n; i++) {
    await new Promise(r => setTimeout(r, 0));
    flush();
  }
};

// A manually pumped AsyncIterable — what a live() server-function stream
// materializes as on the client.
function stream<T>() {
  const buffered: IteratorResult<T>[] = [];
  let waiter: ((r: IteratorResult<T>) => void) | null = null;
  const iterable: AsyncIterable<T> = {
    [Symbol.asyncIterator]: () => ({
      next: () =>
        new Promise<IteratorResult<T>>(res => {
          if (buffered.length) res(buffered.shift()!);
          else waiter = res;
        }),
      return: () => Promise.resolve({ done: true as const, value: undefined })
    })
  };
  return {
    iterable,
    push(value: T) {
      const r = { done: false as const, value };
      if (waiter) {
        const w = waiter;
        waiter = null;
        w(r);
      } else buffered.push(r);
    }
  };
}

test("until() sees a confirming frame that landed on the held store before until() was called", async () => {
  const feed = stream<Snapshot>();
  let answer!: (row: Row) => void;
  const mutation = new Promise<Row>(res => (answer = res));
  const views: string[] = [];
  let reserve!: () => Promise<unknown>;

  createRoot(() => {
    const [store, setStore] = createOptimisticStore<Snapshot>(() => feed.iterable, { rows: [] }, { key: "id" });
    reserve = action(async function* () {
      setStore(d => {
        d.rows.push({ id: "temp", status: "pending" });
      });
      const saved = await mutation;
      yield until(() => store.rows.some(r => r.id === saved.id), { timeout: 200 });
    });
    createRenderEffect(
      () => store.rows.map(r => `${r.id}:${r.status}`).join(",") || "empty",
      v => {
        views.push(v);
      }
    );
  });

  feed.push({ rows: [] });
  await settle();
  expect(views.at(-1)).toBe("empty");

  const done = reserve().then(
    () => "settled",
    e => e
  );
  await settle();
  expect(views.at(-1)).toBe("temp:pending");

  // Server mutates and broadcasts first — the frame lands while the action
  // is still awaiting the mutation's answer, and is held under its optimism.
  feed.push({ rows: [{ id: "res_1", status: "confirmed" }] });
  await settle();
  expect(views.at(-1)).toBe("temp:pending");

  // Then it answers, and the action reaches its until().
  answer({ id: "res_1", status: "confirmed" });
  await settle();

  const outcome = await done;
  expect(outcome).not.toBeInstanceOf(TimeoutError);
  expect(outcome).toBe("settled");
  await settle();
  expect(views.at(-1)).toBe("res_1:confirmed");
});

Expected behavior

The held frame is the confirmation the predicate names, so until() should flip and the action should settle, revealing the frame with the overlay revert (the same outcome as when the frame lands after until() subscribed).

Screenshots or Videos

No response

Platform

  • OS: macOS
  • Node: 25.1.0
  • @solidjs/signals: next @ 37ea885 (fails), 3fc2c54 (fails), d80cd1f (first bad), 61a114c (passes), 2.0.0-rc.8 published (passes)

Additional context

Proposed fix (one early return in enterStagedRead, mainline branch, after the _verdictPull check). A direct-commit reader is the documented tunnel through a hold and applies on its own microtask, so it should be neither entered nor born held, the same exemption verdict pulls and optimistic nodes already get:

     if (GlobalQueue._verdictPull) return;
+    // A promise-delivery effect (resolve()/until(), CONFIG_DIRECT_COMMIT)
+    // reads staged truth by contract — it is the tunnel that keeps a hold
+    // deadlock-free — and applies on its own microtask, so it is neither
+    // entered nor born held. Born held, an until() created mainline after
+    // the confirming frame was staged would replay only at the commit its
+    // own promise is holding open (#3451 follow-up).
+    if (ctx._config & CONFIG_DIRECT_COMMIT) return;
     if (
       ctx._flags & REACTIVE_RECOMPUTING_DEPS &&

With that change the test above passes, and so do until, until-entanglement, born-held, resolve, refresh-await, superseded-before-first-commit, visibility-oracle and the rest of the signals suite (including dist-artifacts after a rebuild). Happy to open a PR with the test and the fix.

Workaround in the meantime: settle the action with await mutation; yield refresh(store) instead of until(); the refreshed answer stages into the held transaction and reveals atomically with the overlay revert.

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