Skip to content

fix(web): stop AsyncQueue.fail from ending a parked consumer normally - #721

Open
ayaangazali wants to merge 1 commit into
RunanywhereAI:mainfrom
ayaangazali:fix/web-asyncqueue-fail
Open

fix(web): stop AsyncQueue.fail from ending a parked consumer normally#721
ayaangazali wants to merge 1 commit into
RunanywhereAI:mainfrom
ayaangazali:fix/web-asyncqueue-fail

Conversation

@ayaangazali

@ayaangazali ayaangazali commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

What is wrong

AsyncQueue.fail() resolved a parked consumer with { done: true } and only stored the error:

fail(error: Error): void {
  if (this.done) return;
  this.done = true;
  this.error = error;
  if (this.resolveNext) {
    const r = this.resolveNext;
    this.resolveNext = null;
    r({ value: undefined as unknown as T, done: true });   // <- normal end
  }
}

The stored this.error is only ever read by next(). A consumer that was parked has already been handed a normal end-of-stream, its for await loop has exited, and it never calls next() again. So whether a caller sees the failure or a clean completion depends on whether it happened to be awaiting at that instant.

During streaming it almost always is. The consumer drains tokens faster than inference produces them, so parked is the normal state, and the silent path is the common one rather than the edge case.

Why it matters

streamGenerate fails both queues on a mid-stream error (RunAnywhere+TextGeneration.ts:183-184). With the queue swallowing it, iterating stream or events ends as if generation had completed: the caller keeps a truncated answer and is told nothing went wrong.

The error does survive on the separate result promise, but LLMStreamingResult exposes stream, events and result independently, and a caller driving the token stream has no obligation to await result.

What this changes

Producers now only signal, and next() re-reads the queue state, so a parked consumer raises the error on the same check a late consumer already hit.

That is exactly how AsyncQueue in @runanywhere/proto-ts/streams/push (the shared implementation used by React Native and Electron) already behaves. This file is a hand-rolled second copy that drifted from it, so this brings the clone back in line rather than inventing a third behaviour.

Buffered values still drain before the error surfaces, and complete() still ends the stream normally. Net 25 insertions, 24 deletions.

Verification

Ran in bindings/web/packages/core:

  • vitest run: 57 files, 248 tests, all pass (including the 4 new ones)
  • tsc --noEmit: clean
  • eslint src/Foundation/AsyncQueue.ts tests/unit/Foundation/AsyncQueue.test.ts --max-warnings 0: clean

The new test is in tests/unit/Foundation/AsyncQueue.test.ts. I confirmed it is a real regression test by stashing only AsyncQueue.ts and re-running: the parked-consumer case fails on unfixed source with

- Error { "message": "rejected promise" }
+ { "done": true, "value": undefined }

and the other three pass on unfixed source, so they pin behaviour this change had to preserve rather than describing the fix.

The suite and typecheck need the generated proto tree, so I ran idl/codegen/generate_ts.sh, generate_ts_convenience.py, generate_defaults_pool.py and generate_streams.sh first. None of that output is committed; git status shows only the two files.

Summary by CodeRabbit

  • Bug Fixes

    • Improved asynchronous queue handling for waiting consumers.
    • Ensured queued values are delivered before reported failures.
    • Improved behavior for consumers after completion or failure.
  • Tests

    • Added coverage for queue failures, buffered values, parked consumers, and normal completion.

Copilot AI lite review requested due to automatic review settings August 16, 2026 17:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4a39592e-5531-4f7c-b744-5895f7f6432f

📥 Commits

Reviewing files that changed from the base of the PR and between c2091a8 and 78ec3a5.

📒 Files selected for processing (2)
  • bindings/web/packages/core/src/Foundation/AsyncQueue.ts
  • bindings/web/packages/core/tests/unit/Foundation/AsyncQueue.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • bindings/web/packages/core/tests/unit/Foundation/AsyncQueue.test.ts
  • bindings/web/packages/core/src/Foundation/AsyncQueue.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.


📝 Walkthrough

Walkthrough

AsyncQueue now uses wake signaling and an asynchronous retry loop for consumer reads. Producers signal consumers after enqueue, completion, or failure. Tests cover failure propagation, buffered values, and normal completion.

Changes

AsyncQueue behavior

Layer / File(s) Summary
Wake signaling and consumer retry
bindings/web/packages/core/src/Foundation/AsyncQueue.ts
AsyncQueue signals parked consumers after state changes. next() rechecks buffered values, errors, and completion before waiting again.
Failure and completion validation
bindings/web/packages/core/tests/unit/Foundation/AsyncQueue.test.ts
Tests verify failure propagation for parked and future consumers, buffered-value draining, and successful completion.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 78ec3

This change makes parked streaming consumers receive mid-stream failures instead of silently treating truncated output as normal completion. No actionable merge-blocking risk remains after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant Producer
  participant AsyncQueue
  participant Consumer
  Producer->>AsyncQueue: push, complete, or fail
  AsyncQueue->>Consumer: signal parked consumer
  Consumer->>AsyncQueue: next()
  AsyncQueue-->>Consumer: buffered value, error, or completion
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly explains the defect, impact, implementation, compatibility behavior, and verification results. It does not use every template heading or checklist item, but the missing items a…
Title check ✅ Passed The title clearly identifies the web AsyncQueue failure-handling bug and matches the primary change: parked consumers must receive the failure instead of normal completion.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 2…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description clearly explains the defect, impact, implementation, compatibility behavior, and verification results. It does not use every template heading or checklist item, but the missing items are non-critical for this change.

Full details: Docstring Coverage

Explanation

No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 2 files.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ayaangazali

Copy link
Copy Markdown
Contributor Author

Rebased onto 0105aed1d, diff unchanged, checks green.

Still reproduces on main (bindings/web/packages/core/src/Foundation/AsyncQueue.ts:56):

  fail(error: Error): void {
    if (this.done) return;
    this.done = true;
    this.error = error;
    if (this.resolveNext) {
      const r = this.resolveNext;
      this.resolveNext = null;
      r({ value: undefined as unknown as T, done: true });   // <- resolves, never throws
    }
  }

A consumer already parked in next() gets a clean end-of-stream instead of the error. Only a consumer that happens to call next() after fail() reaches the if (this.error) return Promise.reject(...) branch, and for a token queue the parked case is the normal one.

One thing worth raising rather than sitting on, since it may change what you want here: this file duplicates a class that already exists and already gets this right. @runanywhere/proto-ts/streams/push exports an AsyncQueue whose next() drains the buffer, then throws a retained failure, then reports done. bindings/web/packages/core already depends on that package and already imports iterableFromSubscription from that exact module in Foundation/EventBus.ts:41, and Electron consumes the shared class directly in src/api/iter.ts.

So there is a smaller change available than this one: delete the 79-line web copy and point the four importers (stt.ts, vad.ts, voice.ts, RunAnywhere+TextGeneration.ts) at the shared class. That fixes this bug by removing the code containing it, and also gives web the return() the local copy lacks.

I did not do that here because it is a different change from the one you have been sent, and I did not want to swap it under you. Say the word and I will convert this PR to the deletion, or keep this as the minimal fix.

@ayaangazali
ayaangazali force-pushed the fix/web-asyncqueue-fail branch from d2ff1ab to 4bb9dee Compare August 24, 2026 18:44
`fail()` resolved a parked consumer with `{ done: true }` and only stored the
error, so the error was reachable solely from a later `next()` that a finished
`for await` loop never makes. Whether a caller saw the failure or a clean
end-of-stream depended on whether it happened to be awaiting at that instant,
and during streaming it almost always is: the consumer drains tokens faster
than inference produces them, so it is parked nearly all the time.

The visible effect on `streamGenerate` (RunAnywhere+TextGeneration.ts:183,
which fails both the token queue and the event queue on a mid-stream error) is
that iterating `stream` or `events` ends as though generation completed. The
caller keeps a truncated answer and is told nothing. The error survives only on
the separate `result` promise, which a streaming caller need not await.

Producers now just signal and `next()` re-reads the queue state, so the error
is raised on the same check that a late consumer already hit. That is how
`AsyncQueue` in `@runanywhere/proto-ts/streams/push` already works, and this
file is a hand-rolled second copy of it that drifted; matching it removes the
divergence rather than adding a second way to be right.

Buffered values still drain before the error, and `complete()` still ends the
stream normally.
@ayaangazali
ayaangazali force-pushed the fix/web-asyncqueue-fail branch from 4bb9dee to 78ec3a5 Compare August 25, 2026 19:03
@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

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.

2 participants