Skip to content

[draft] feat: implement Chan<T> and tee() primitives with Python parity#1200

Open
toubatbrian wants to merge 2 commits intomainfrom
devin/1775205203-chan-primitive
Open

[draft] feat: implement Chan<T> and tee() primitives with Python parity#1200
toubatbrian wants to merge 2 commits intomainfrom
devin/1775205203-chan-primitive

Conversation

@toubatbrian
Copy link
Copy Markdown
Contributor

@toubatbrian toubatbrian commented Apr 3, 2026

Description

Introduces two new async primitives — Chan<T> and tee() — ported from Python's aio.Chan[T] and aio.itertools.tee. These are the foundation for replacing ReadableStream usage across the codebase with async channel queues and async generators (per the JS-ification design doc).

This PR is additive only — no existing code is modified (aside from re-exports in stream/index.ts). The broader migration across the codebase will follow in subsequent PRs.

Changes Made

  • agents/src/stream/chan.tsChan<T> class: blocking/non-blocking send/recv, backpressure via maxsize, close semantics with buffer draining, async iteration (for await...of), optional AbortSignal support via iter(signal?). Ref: Python livekit-agents/livekit/agents/utils/aio/channel.py.
  • agents/src/stream/tee.tsTee<T> class and tee() function: splits one AsyncIterable<T> into N independent iterators with shared buffer, lock-based upstream coordination, error propagation, and upstream cleanup when the last peer closes. Ref: Python livekit-agents/livekit/agents/utils/aio/itertools.py.
  • agents/src/stream/chan.test.ts — 42 tests for Chan<T>.
  • agents/src/stream/tee.test.ts — 18 tests for tee().
  • agents/src/stream/index.ts — Re-exports the new primitives.

Pre-Review Checklist

  • Build passes: pnpm format:check passes; pnpm lint has only pre-existing warnings unrelated to this PR
  • AI-generated code reviewed: Removed unnecessary comments and ensured code quality
  • Changes explained: All changes are properly documented and justified above
  • Scope appropriate: All changes are new additive files; no existing behavior modified
  • Video demo: N/A — these are internal primitives with no user-facing UI

Testing

  • 60 automated tests added (42 for Chan, 18 for tee), all passing
  • Tests cover: basic operations, backpressure, close semantics, abort signals, resource leak prevention (waiter cleanup), error propagation, concurrent readers/writers, edge cases (rapid cycles, 10k items, nested tee)

Items for Reviewer Attention

  1. Chan.iter() abort error handling (chan.ts:240-243): When signal?.aborted is true, any caught error is silently swallowed (not just AbortError). This matches the intent (stop iteration on abort), but could mask real errors that coincidentally occur at the same tick as an abort.

  2. closeIterator workaround for unstarted generators (tee.ts:29-34): JS async generators skip try/finally if return() is called before next(). The workaround calls next() once to "start" the generator before calling return(). This is correct but could cause unexpected side effects if the upstream source does significant work on its first iteration.

  3. Lock in tee.ts: Simple mutex with no timeout or deadlock detection. Sufficient for the tee use case (short critical sections), but worth noting if this class is ever reused.

  4. Chan.close() getter ordering (chan.ts:185-196): When closing with more blocked receivers than buffered items, excess receivers are popped from the end (LIFO) and rejected, while the remaining receivers are woken FIFO. Verify this matches Python's behavior.

Additional Notes

This is Phase 1 of the ReadableStream→Chan migration. Next steps:

  • Adapter utilities (fromReadableStream, toReadableStream) for interop with external APIs
  • Wave 1 migration: update type signatures in voice/io.ts, voice/agent.ts, utils.ts

Link to Devin session: https://livekit.devinenterprise.com/sessions/6f09b4044c3e4950ad2673781e2f0ba9
Requested by: @toubatbrian

Port Python's aio.Chan[T] and aio.itertools.tee to JS as foundation
for replacing ReadableStream with async channel queues + async generators.

Chan<T>:
- Blocking/non-blocking send/recv with backpressure (maxsize)
- Clean close semantics (drain buffer, wake all waiters)
- Async iteration via for-await-of with auto-stop on close
- Optional AbortSignal integration for iteration control
- Resource leak prevention (waiter cleanup)

tee():
- Split one AsyncIterable<T> into N independent iterators
- Shared buffer with lock-based coordination
- Error propagation across all peers
- Upstream cleanup when last peer closes

Includes 60 comprehensive unit tests covering:
- Basic operations, backpressure, close semantics
- AbortSignal support, resource leak prevention
- Error propagation, partial consumption, nested tee

Co-Authored-By: brian.yin <brian.yin@livekit.io>
@devin-ai-integration
Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 3, 2026

⚠️ No Changeset found

Latest commit: c985a06

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@toubatbrian toubatbrian changed the title feat: implement Chan<T> and tee() primitives with Python parity [draft] feat: implement Chan<T> and tee() primitives with Python parity Apr 3, 2026
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 26f2744975

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +29 to +31
if (!started) {
try {
await iterator.next();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not block close on iterator.next() for unstarted sources

When started is false, closeIterator waits on iterator.next() before calling return(). If the upstream generator blocks before its first yield (for example, it awaits external input), then closing the last tee peer (or calling Tee.aclose()) can hang forever on that next() call, which can stall shutdown and cancellation flows.

Useful? React with 👍 / 👎.

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.

Fixed in c985a06. Removed the next() workaround entirely — closeIterator now just calls return() directly. For unstarted generators, return() completes immediately without running finally blocks. This matches Python's behavior (aclose() on an unstarted generator is a no-op) and is safe because no resources were acquired inside the generator body if it was never started. Added regression test should not hang when closing unstarted tee over a blocking source.


// Ensure upstream is closed even if peer cleanup didn't trigger it
try {
await closeIterator(this._iterator, true);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Pass the real started state in Tee.aclose fallback

Tee.aclose() always invokes closeIterator(this._iterator, true) in its fallback path, which skips the unstarted-generator workaround. In cases like tee(source, 0) where no child ever advances the iterator, this prevents the generator finally block from running during close, so upstream cleanup code may never execute.

Useful? React with 👍 / 👎.

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.

Fixed in c985a06. Removed the _started tracking entirely — closeIterator no longer takes a started parameter. Tee.aclose() and _removeSelfAsync() now just call closeIterator(iterator) without any started state. The return() call handles both started and unstarted generators correctly without the workaround.

Address Codex review comments:
- P1: closeIterator no longer calls next() on unstarted generators,
  which could block forever if the generator awaits external input
  before its first yield.
- P2: Tee.aclose() no longer hardcodes started=true; the started
  tracking is removed entirely since closeIterator no longer needs it.

This matches Python's behavior: aclose() on an unstarted generator
is a no-op (finally blocks don't run). This is safe because no
resources were acquired inside the generator body if it was never
started.

Co-Authored-By: brian.yin <brian.yin@livekit.io>
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.

1 participant