Skip to content

Add has_receivers/has_senders and fix BroadcastChannel auto-close - #565

Open
shsms wants to merge 15 commits into
frequenz-floss:v2.x.xfrom
shsms:broadcast-channel-has-receivers
Open

shsms wants to merge 15 commits into
frequenz-floss:v2.x.xfrom
shsms:broadcast-channel-has-receivers

Conversation

@shsms

@shsms shsms commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

BroadcastChannel (new in 1.12) closed itself on the first send without receivers, and producers had no way to tell whether anyone was listening. This branch keeps the channel open in that case, like tokio's broadcast channel, fixes several gaps in closing, and adds BroadcastSender.has_receivers and BroadcastReceiver.has_senders.

Changes

  • Closing a receiver now wakes a task that waits on it. Receivers wait on an Event that is replaced on each wake-up, instead of an asyncio.Condition. Waiters left on a closed event loop no longer make close(), send() or aclose() raise. Please look closely at _wake_receivers() and BroadcastReceiver.ready().
  • Closing a receiver drops its buffered messages, so it is never ready, and select() no longer loops on it.
  • A send() without receivers raises the new NoReceiversError (a SenderError) and drops the message, but leaves the channel open, so new receivers can still be subscribed. With resend_latest=True, the message is still kept, so a new receiver gets the real latest value. Only closing the senders closes the channel.
  • A sender dropped without being closed keeps the channel open. The docs recommend contextlib.aclosing().
  • clone() refuses a closed sender or channel. subscribe() uses the limit and warn_on_overflow the channel was created with as its defaults.
  • BroadcastChannel no longer emits Broadcast's deprecation warning. The class is now private, and Broadcast is a deprecated alias of it, made with deprecated_aliases() from the new frequenz-core dependency. Its docstring is now short and points to BroadcastChannel.
  • The experimental Pipe closes its sender when its receiver stops, and both ends when a send fails. With close_pipe_on_send_error=False, it drops the message after a failed send and keeps sending the messages that follow. stop() still leaves both open.
  • Docs: the user guide, README, package overview and all docstring examples use BroadcastChannel. There is a new Oneshot page, the Anycast page is gone, and several false claims about closing are fixed.

Breaking

  • An auto-closing channel no longer closes on a send() without receivers. send() raises NoReceiversError instead, and subscribe() keeps working.
  • A Pipe now closes its sender when its receiver stops, and both ends when a send fails with a SenderError.
  • BroadcastSender.clone() raises SenderClosedError or SenderError on a closed sender or channel.
  • BroadcastSender.subscribe() takes its limit and warn_on_overflow defaults from the channel.
  • BroadcastReceiver.close() now wakes waiting tasks, so it must be called from the thread running the event loop.
  • The Broadcast deprecation warning fires when the name is imported or accessed, not on creation, and type checkers no longer report it as deprecated.
  • This package now depends on frequenz-core >= 1.6.0, < 2, and needs typing-extensions >= 4.13.0.

@shsms
shsms requested a review from a team as a code owner October 20, 2026 16:08
@shsms
shsms requested review from Marenz and removed request for a team October 20, 2026 16:08
@github-actions github-actions Bot added part:docs Affects the documentation part:tests Affects the unit, integration and performance (benchmarks) tests part:channels Affects channels implementation part:synchronization Affects the synchronization of multiple sources (`select`, `merge`) part:core Affects the core types (`Sender`, `Receiver`, exceptions, etc.) part:utilities Affects the utility receivers (`Timer`, `Event`, `FileWatcher`) part:experimental Affects the experimental package labels Oct 20, 2026
@shsms
shsms marked this pull request as draft October 20, 2026 17:19
@shsms
shsms force-pushed the broadcast-channel-has-receivers branch from 64050f2 to e723c15 Compare October 23, 2026 11:37
@shsms
shsms marked this pull request as ready for review October 23, 2026 12:16
Marenz
Marenz previously approved these changes Oct 23, 2026

@Marenz Marenz 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.

🤖 LGTM. This fixes real bugs, and the tests cover the hard cases (GC in another thread, closed loops, double detach). The release notes are accurate. ci_checks_max and pytest_min pass locally (152 tests).

Nits:

  1. The PR description says "The channel still closes on the next send() without receivers, as in 1.12". The code does the opposite: send() raises NoReceiversError and keeps the channel open (_broadcast.py:499), as commit 8278c97 and the release notes say. Please fix the description and add this to the "Breaking" list (before, that send closed the channel and raised SenderError with a ChannelClosedError cause).
  2. BroadcastReceiver.close() wakes receivers directly (_broadcast.py:756), while the sender finalizer uses call_soon_threadsafe(). With asyncio debug off, calling close() from another thread leaves a waiting task stuck without any error; the thread test only covers debug mode. A docstring note is probably enough, or route it through _wake_receivers_soon().
  3. _sender_count -= 1 (_broadcast.py:182) runs in the finalizer, which can be any thread, and is not atomic. Worth a comment that only the wake-up is thread-safe.
  4. _Broadcast is private, so there is no public type left to check ChannelClosedError.channel against. Maybe say so in the release notes.

@llucax llucax added scope:breaking-change Breaking change, users will need to update their code type:bug Something isn't working type:enhancement New feature or enhancement visitble to users labels Oct 28, 2026
@shsms
shsms force-pushed the broadcast-channel-has-receivers branch from e723c15 to 6865c28 Compare October 28, 2026 14:13
@github-actions github-actions Bot added the part:tooling Affects the development tooling (CI, deployment, dependency management, etc.) label Oct 28, 2026

@llucax llucax 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.

My main concern here is I'm not convinced about the messing around with the GC, the rest is mostly minor. I will add another review/comment about the breaking changes and how we should ship this (v1 or v2).

I also did a AI review:

🤖 rAIview (AI review here!)

The lifecycle fixes and expanded documentation address several real problems. I found one remaining garbage-collection regression: the endpoint finalizers retain channels strongly, which can keep otherwise unreachable channels and cached payloads alive indefinitely. Both finalizer callbacks should refer to the channel weakly.

Comment thread src/frequenz/channels/_broadcast.py Outdated

self._channel._sender_count += 1
# pylint: disable=protected-access
self._detach = weakref.finalize(self, channel._remove_sender)

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.

🟡⬇️ Avoid retaining channels strongly in endpoint finalizers

weakref.finalize(self, channel._remove_sender) stores a bound method, so the finalizer registry keeps channel alive. The receiver finalizer does the same with channel._remove_receiver. Although the channel stores its receivers weakly, it stores _latest strongly, even with the default resend_latest=False. A message can therefore create an indirect strong reference back to an endpoint, for example when two channels exchange reply senders, or when a message contains an object that owns an endpoint.

Once every application reference is dropped, the graph is still reachable through finalize registry → bound callback → channel → _latest → endpoint. This is not just a collectable reference cycle: the global registry prevents collection, so the finalizer that would release the graph never runs. Python's weakref.finalize documentation explicitly warns against callback references to the finalized object, including indirect references.

I reproduced this with two BroadcastChannel[object] instances that send each other's senders, drain both receiver buffers, and then drop all four endpoints. After gc.collect(), both senders remain alive on this PR, while all four endpoints are collected on the base branch. The receiver callback independently causes the same problem for a legacy Broadcast whose cached message references its receiver, even after its sender is explicitly closed.

Impact

Applications that repeatedly create and discard channels with endpoint-containing payloads can accumulate channels, finalizers, cached messages, and everything those messages reference until process exit or explicit cleanup. Draining the receiver buffers does not avoid the leak.

Related locations

Suggested fix (⬇️ effort)

Use a callback that receives only a weakref.ref(channel), dereferences it once, and calls the removal method only if the channel is still alive. Apply this to both endpoint finalizers, without capturing a bound channel method in the callback or its arguments. Endpoints already keep their channel alive while application references to them exist. A runtime probe using weak channel callbacks collected both reproduced cycles and still woke a live receiver when its last sender was collected.

Suggested tests

Add collection tests for both finalizers, including a cross-channel cycle with drained buffers and a receiver cycle after explicit sender closure.

Suggested new tests
import gc
import weakref

from frequenz.channels import Broadcast, BroadcastChannel


async def test_unreachable_cross_channel_cycle_is_collected() -> None:
    sender1, receiver1 = BroadcastChannel[object](name="requests")
    sender2, receiver2 = BroadcastChannel[object](name="replies")
    refs = [weakref.ref(x) for x in (sender1, receiver1, sender2, receiver2)]

    await sender1.send(sender2)
    await sender2.send(sender1)
    await receiver1.receive()
    await receiver2.receive()
    del sender1, receiver1, sender2, receiver2
    gc.collect()

    assert all(ref() is None for ref in refs)


async def test_unreachable_receiver_cycle_is_collected() -> None:
    channel = Broadcast[object](name="receiver-cycle")
    sender = channel.new_sender()
    receiver = channel.new_receiver()
    ref = weakref.ref(receiver)

    await sender.send(receiver)
    await receiver.receive()
    await sender.aclose()
    del channel, sender, receiver
    gc.collect()

    assert ref() is None

🟡 Medium Severity: Probably best to fix before merging; skipping needs a good reason.

⬇️ Low Effort: A small callback change and focused regression tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed by removing both finalizers, so nothing global holds the channel any more.

Comment thread docs/user-guide/channels/index.md Outdated
Comment on lines +38 to +40
* [Anycast](anycast.md)
* [Broadcast](broadcast.md)
* [Oneshot](oneshot.md)
* [Anycast](anycast.md) (deprecated)

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.

Nitpick: This was sorted alphabetically, I would keep the order. We could also just remove it from here, do don't need to promote deprecated stuff...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Dropped anycast

Comment thread src/frequenz/channels/_broadcast.py Outdated
Comment on lines +171 to +188
def _remove_sender(self, *, from_gc: bool = True) -> None:
"""Detach a closed or garbage-collected sender from this channel.

The garbage collector can run this in any thread, and in the middle of
other code. So when it does, this only updates the channel's state, and
the receivers are woken up later, on the event loop they wait on.

Args:
from_gc: Whether the garbage collector runs this, through the sender's
finalizer.
"""
self._sender_count -= 1
if self._auto_close_enabled and self._sender_count == 0:
if from_gc:
self._mark_closed()
self._wake_receivers_soon()
else:
self._close()

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.

I'm not sure about this (and the "Auto-close broadcast channels when the last sender is dropped" commit in general). Is this supposed to only fix when someone forgets to call aclose() on the receiver, (a bug basically) or is this handling a valid use case?

Senders that were garbage-collected without aclose() ...

I can't see any cases where a sender could be garbage-collected without aclose() that is not a user forgetting to call aclose() (a bug).

The garbage collector can detach a sender in any thread, or in the
middle of Event.wait().

Why? How can the garbage collector detach anything?

If it is to catch a case where users forgot the aclose() I wouldn't go to the trouble of trying to clean stuff for them. All the weakref.finalize() business is usually very fragile, and I wouldn't be surprised if we introduce more subtle bugs with it, so it is basically extra complexity (storing the loop, calling call_soon_threadsafe(), using weakref.finalize(), having code running inside the GC, etc.) for nothing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Damn it, it is for handling the user bug of forgetting to close, but it would have been very useful for that, especially with all the agentic coding and it is also get super hard to find out which receiver or sender wasn't closed, and we'll give up too easily instead of trying to debug properly.

But I agree that the implementation is buggy and looks like it can't be perfect, so I've dropped it.

Comment thread src/frequenz/channels/_broadcast.py Outdated
Comment on lines +197 to +203
def _remove_receiver(self, key: int) -> None:
"""Detach a closed or garbage-collected receiver from this channel.

Args:
key: The key of the receiver in `self._receivers`.
"""
self._receivers.pop(key, None)

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.

Same for commit "Detach garbage-collected broadcast receivers at once".

Garbage-collected receivers stayed in the channel until the next send() swept them out. They now detach as soon as they are collected, so the channel doesn't keep dead receivers between sends.

Why do we need this? If an opened receiver is garbage collected, that's a user bug IMHO, they should close them before forgetting the last reference to them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

gone

Comment on lines +671 to +683
@property
def has_senders(self) -> bool:
"""Whether this receiver's channel is open and has any senders.

A [`BroadcastChannel`][frequenz.channels.BroadcastChannel] is closed as soon
as its last sender is closed or garbage-collected. So once this is `False`
on a `BroadcastChannel`, it stays `False`, and no new messages will arrive.
The messages already in this receiver's buffer can still be received.

This doesn't check whether this receiver itself is closed.
"""
# pylint: disable=protected-access
return not self._channel._closed and self._channel._sender_count > 0

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.

What about num_senders (and num_receivers) instead? You can still use it as a bool but can also get the amount.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

sender_count already exists, IIRC, that was the old way of identifying whether someone should wait on a receiver or not, don't remember.

Any way, num_senders would be weird when there's also sender_count.

Also, sender_count sends a value even if the receiver is closed, which might not be ideal, I guess we should actually deprecate that.

has_receivers etc gives enough info. I think exact counts is too much info, at least for the way we want to be using this field.

Comment thread src/frequenz/channels/_broadcast.py Outdated
Comment on lines +6 to +7
# The docstrings carry long examples.
# pylint: disable=too-many-lines

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.

Nitpick: We could consider splitting into more files, like _broadcast_sender.py and _broadcast_receiver.py or such.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed by trimming the docstring of the deprecated Broadcast, asking people to use BroadcastChannel instead.

Comment thread src/frequenz/channels/_broadcast.py Outdated
Comment on lines +265 to +268
!!! Warning "Deprecated"

Please use [`BroadcastChannel`][frequenz.channels.BroadcastChannel] instead.

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.

I would remove this, the next repo-config will add the admonition automatically.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

Comment thread docs/user-guide/channels/anycast.md Outdated

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.

If we remove this file from the index, we should probably remove the file too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

Comment thread src/frequenz/channels/__init__.py Outdated
* [`BroadcastChannel`][frequenz.channels.BroadcastChannel]: A channel to broadcast
messages from multiple senders to multiple receivers. Each message sent through any
of the senders is received by all of the receivers. The channel closes itself when
all its senders are closed or garbage-collected. A `send()` while it has no

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.

If we stop handling garbage collected stuff, we should remove these (and all other gc mentions) too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

Comment on lines +120 to +132
except SenderError as error:
if isinstance(error, NoReceiversError):
_logger.warning(
"Pipe from [%s] to [%s] stopped: the sender has no receivers",
self._receiver,
self._sender,
)
else:
_logger.exception(
"Pipe from [%s] to [%s] stopped: sending failed",
self._receiver,
self._sender,
)

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.

What? Why not:

Suggested change
except SenderError as error:
if isinstance(error, NoReceiversError):
_logger.warning(
"Pipe from [%s] to [%s] stopped: the sender has no receivers",
self._receiver,
self._sender,
)
else:
_logger.exception(
"Pipe from [%s] to [%s] stopped: sending failed",
self._receiver,
self._sender,
)
except NoReceiversError:
_logger.warning(
"Pipe from [%s] to [%s] stopped: the sender has no receivers",
self._receiver,
self._sender,
)
except SenderError:
_logger.exception(
"Pipe from [%s] to [%s] stopped: sending failed",
self._receiver,
self._sender,
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

@llucax llucax 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.

I checked several internal consumers to understand the practical compatibility impact. Most still use the legacy, non-auto-closing Broadcast, so these changes appear to affect us less than the breaking-change list initially suggests. Only a couple of the inspected consumers use Pipe, and their normal ownership and teardown patterns don’t look immediately broken by this change. That is reassuring for our current usage, but it doesn’t establish compatibility for other users.

I’d suggest the following release plan:

  1. Release the backward-compatible fixes and additive APIs in 1.x, while preserving existing defaults and public contracts.
  2. Make the new behavior for sending without receivers opt-in in 1.x. The existing behavior should remain the default; users should explicitly choose to raise NoReceiversError without closing the channel.
  3. Optionally, make Pipe’s new endpoint-closing behavior opt-in in 1.x too. Closing caller-supplied endpoints changes ownership expectations, even if the current internal call sites appear compatible. However, given that Pipe is experimental, I’d also be comfortable making an explicit, documented exception and shipping this breaking change in 1.x.

For each compatibility option we introduce, I’d use this transition:

Release Default and migration
1.x Preserve the old behavior; allow opting into the new behavior.
2.x Make the new behavior the default, but retain the compatibility option. Emit a DeprecationWarning when the old behavior is explicitly selected.
3.x Remove the old behavior and the compatibility option.

Keeping the options in 2.x would let applications adopt the new behavior while still on 1.x and carry that configuration across the major-version upgrade. Once we agree on the approach, we can track their eventual removal in a separate 3.x breaking-changes issue.

If implementing and maintaining this compatibility would be too much work, I’d instead create a v2.x.x branch and target this PR there. We already have some accumulated deprecations, so we could also take the opportunity to remove deprecated APIs and do some cleanup for 2.0.0. That would make it a useful major release rather than introducing compatibility machinery just to keep these changes in 1.x.

Separately, if we already rely on Pipe in real applications, I think it’s probably time to promote it out of experimental. That should include clear documentation of endpoint ownership, normal completion, send failures, and cancellation, plus a migration path for the existing import. It could be a separate focused PR, but it seems worth doing alongside this lifecycle work.

@shsms
shsms force-pushed the broadcast-channel-has-receivers branch from 6865c28 to 8b21c5b Compare October 31, 2026 14:44
shsms added 7 commits November 2, 2026 15:58
`BroadcastReceiver.close()` is synchronous and couldn't notify the
channel's condition variable, so a task waiting in `ready()` hung until
the next message arrived or the channel was closed.  Receivers now wait
on an event that can be set from synchronous code.

Waiters left on a closed event loop are skipped, so they don't make
`close()` fail.  For the same reason, `send()` and `aclose()` no longer
raise `RuntimeError: Event loop is closed` because of them.

Signed-off-by: Sahas Subramanian <sahas.subramanian@proton.me>
A closed receiver with messages left in its buffer kept returning True
from `ready()`, while `consume()` raised `ReceiverStoppedError`.  So
`select()` yielded stopped selections for it forever, and a `filter()`
on it made `select()` fail.  Closing a receiver now drops its buffered
messages, so it is never ready.

Signed-off-by: Sahas Subramanian <sahas.subramanian@proton.me>
Like tokio's broadcast channel, a send to an auto-closing broadcast
channel without receivers still fails, but now leaves the channel open,
so new receivers can still be subscribed.  `send()` raises a
`NoReceiversError` and drops the message, but with `resend_latest=True`
it still keeps the message, so new receivers get the real latest one.
Such a channel now closes itself only when it has no open senders.

Signed-off-by: Sahas Subramanian <sahas.subramanian@proton.me>
Unlike `send()` and `subscribe()`, `clone()` worked on a closed sender
or channel.  On a closed channel, it handed out senders that could never
send.  It now raises like `send()` and `subscribe()`, through a check
that the three methods share.

Signed-off-by: Sahas Subramanian <sahas.subramanian@proton.me>
`limit` and `warn_on_overflow` were documented as applying to the
channel's receivers, but only the initial receiver got them;
`subscribe()` always fell back to 50 and `True`.  They are now the
defaults for `subscribe()`.

Signed-off-by: Sahas Subramanian <sahas.subramanian@proton.me>
Lets producers stop when nobody is listening anymore, and consumers tell
whether more messages can still arrive, without having to send or wait
on the channel.

Signed-off-by: Sahas Subramanian <sahas.subramanian@proton.me>
`BroadcastChannel` was built on the deprecated `Broadcast` class, so
creating one warned users to "use BroadcastChannel instead".  The class
is now private, and `Broadcast` is a deprecated alias of it, made with
`deprecated_aliases()` from `frequenz-core`.  Reaching the alias warns,
creating a `BroadcastChannel` doesn't.

Signed-off-by: Sahas Subramanian <sahas.subramanian@proton.me>
shsms added 8 commits November 2, 2026 15:58
`Broadcast` is deprecated, but its docstring still repeated the channel
description and examples of `BroadcastChannel`.  It now only says how to
use the class.

Signed-off-by: Sahas Subramanian <sahas.subramanian@proton.me>
About auto-closing, the docs only said that closing all senders closes
all receivers, and what a send without receivers does.  They now also
cover what each side sees, that an unclosed sender keeps the channel
open, and `has_receivers`/`has_senders`.

The multi-sender example is fixed too: it claimed to use a small buffer
to make the senders block, it caught a `ReceiverStoppedError` that
`async for` never raises, and its receivers slept once after the loop
instead of after each message.

The first example now stops when its sender is closed.  The
`BroadcastSender` and `BroadcastReceiver` docs now point to
`BroadcastChannel`, and the `resend_latest` docs point to `subscribe()`.

Signed-off-by: Sahas Subramanian <sahas.subramanian@proton.me>
The `aclose()` docs said that receiving after the buffer is drained
returns `None`; they now say a `ReceiverStoppedError` is raised.  The
receiver guide, `Receiver.close()` and the `map()` and `filter()`
receivers said every closed receiver drains its buffer first; they now
say this depends on the receiver.

`BroadcastReceiver.close()` doesn't reliably wake up tasks when called
from another thread, so its docs now say where to call it.

Signed-off-by: Sahas Subramanian <sahas.subramanian@proton.me>
The user guide, package overview and README still presented the
deprecated `Broadcast` and `Anycast` classes.  They now show
`BroadcastChannel`, the overview marks `Anycast` as deprecated, and the
user guide drops its `Anycast` page.  A new page covers
`OneshotChannel`.  The README showcase now closes its data senders, and
its receiver stops when the data channels close.

Signed-off-by: Sahas Subramanian <sahas.subramanian@proton.me>
The examples used the deprecated `Anycast` and `Broadcast` classes.
Also fix some examples with missing imports, and the latest value cache
examples, which checked the cache before its task could run.  The
`Anycast` docs now point to `BroadcastChannel`.

Signed-off-by: Sahas Subramanian <sahas.subramanian@proton.me>
A `Pipe` whose receiver stopped, or whose send failed with a
`SenderError`, ended but kept its sender open, so the receivers of the
sender's channel could wait forever.  Since a broadcast channel now
stays open without receivers, receivers subscribed after a failed send
waited forever too.  And the pipe's receiver stayed attached to its
source channel, so `has_receivers` stayed `True` there.

Close the sender in both cases, and the receiver too when the send
failed.  `stop()` still leaves both open.

After a send fails with a `SenderError`, the pipe now logs the error
itself: a `NoReceiversError` as a warning, and any other error with its
traceback.  Its task then ends normally, so asyncio logs nothing.  If
closing one of the pipe's ends fails, the task ends with that error.

Signed-off-by: Sahas Subramanian <sahas.subramanian@proton.me>
A failed send stops a `Pipe`, which is wrong when no receivers is a
normal state of the sender's channel.  With
`close_pipe_on_send_error=False`, the pipe drops the message and keeps
sending the messages that follow.  A stopped receiver still closes the
sender and stops the pipe.

With that option, a missing receiver is expected, so it is logged at
debug level, not as a warning for each message.

Signed-off-by: Sahas Subramanian <sahas.subramanian@proton.me>
Signed-off-by: Sahas Subramanian <sahas.subramanian@proton.me>
@shsms
shsms force-pushed the broadcast-channel-has-receivers branch from 8b21c5b to 42053e0 Compare November 2, 2026 14:59
@shsms

shsms commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Also added close_pipe_on_send_error: bool = True to pipe.

@shsms
shsms changed the base branch from v1.x.x to v2.x.x November 2, 2026 15:05

This branch has not been deployed

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

Labels

part:channels Affects channels implementation part:core Affects the core types (`Sender`, `Receiver`, exceptions, etc.) part:docs Affects the documentation part:experimental Affects the experimental package part:synchronization Affects the synchronization of multiple sources (`select`, `merge`) part:tests Affects the unit, integration and performance (benchmarks) tests part:tooling Affects the development tooling (CI, deployment, dependency management, etc.) part:utilities Affects the utility receivers (`Timer`, `Event`, `FileWatcher`) scope:breaking-change Breaking change, users will need to update their code type:bug Something isn't working type:enhancement New feature or enhancement visitble to users

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants