fix(transport): remove nursery param from IListener.listen (adopts #1055)#1308
fix(transport): remove nursery param from IListener.listen (adopts #1055)#1308yashksaini-coder wants to merge 11 commits intolibp2p:mainfrom
Conversation
The caller-supplied nursery was a leaky abstraction that forced Swarm to manage a listener_nursery on behalf of every transport. Listeners should own their own background task lifetimes internally. This commit updates the interface only; concrete implementations follow in subsequent commits. Part of libp2p#726. Co-authored-by: Michael Eze <code.maestro64@gmail.com>
TCPListener now spawns its own internal nursery as a trio system task in listen(). The nursery hosts trio.serve_tcp and stays open until close() cancels it. listen() drops the nursery parameter and returns once the socket is bound (signalled via an internal trio.Event). Startup errors are captured and re-raised as OpenConnectionError to preserve the existing error contract. close() cancels the internal nursery, closes all SocketListeners, and waits for the background system task to finish so callers observe a fully-quiesced listener on return. Safe to call multiple times. Part of libp2p#726. Co-authored-by: Michael Eze <code.maestro64@gmail.com>
…tListener Same pattern as TCPListener: listen() drops the nursery parameter and spawns a trio system task that hosts an internal nursery. serve_websocket runs under that nursery until close() cancels it. Startup errors are captured and re-raised as OpenConnectionError. close() now also awaits the background task's completion after cancelling the nursery, so callers observe a fully-quiesced listener on return. Part of libp2p#726. Co-authored-by: Michael Eze <code.maestro64@gmail.com>
QUIC is a special case: the transport already carries a background nursery via set_background_nursery(). Prefer that nursery when available (the common path when used through a Swarm), and fall back to a self-spawned trio system task with its own internal nursery when it isn't. Either way, the listen() API no longer requires a caller- supplied nursery. close() cancels the self-spawned nursery when it was used and waits for the background task to finish. Part of libp2p#726. Co-authored-by: Michael Eze <code.maestro64@gmail.com>
…istener.listen The CircuitV2 listener never used the nursery parameter — it just records the multiaddr to advertise. Drop the parameter to align with the updated IListener interface. Part of libp2p#726. Co-authored-by: Michael Eze <code.maestro64@gmail.com>
…series Listeners now own their internal nursery, so Swarm no longer manages listener_nursery / event_listener_nursery_created. The outer nursery in run() is still needed for transport-level background tasks (QUIC / WebSocket set_background_nursery) and for the auto-connector, so it is renamed to background_nursery / event_background_nursery_created to reflect its actual purpose. run() now also calls listener.close() on every listener during shutdown, so their internal system-task nurseries are cancelled and fully awaited. listen() drops the nursery argument from its listener.listen() call. Part of libp2p#726. Co-authored-by: Michael Eze <code.maestro64@gmail.com>
Updates the full test suite to match the new IListener.listen(maddr) signature: TCP, WebSocket, QUIC listener/integration tests, shared trio I/O fixtures, and the tests/utils/factories factory. Part of libp2p#726. Co-authored-by: Michael Eze <code.maestro64@gmail.com>
Co-authored-by: Michael Eze <code.maestro64@gmail.com>
There was a problem hiding this comment.
Pull request overview
This PR removes the caller-supplied trio.Nursery from IListener.listen() and updates transports/tests so listeners manage their own background task lifetime and are shut down via close().
Changes:
- Update
IListener.listen(maddr, nursery)tolisten(maddr)and adjust all implementations/callers. - Move listener background task management into transport listeners (TCP/WebSocket/QUIC) and ensure Swarm shutdown explicitly closes listeners.
- Update tests and add a breaking-change newsfragment.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
libp2p/abc.py |
Update IListener.listen abstract method signature/docs to remove nursery. |
libp2p/transport/tcp/tcp.py |
TCP listener now spawns its own background system task/nursery and cancels via close(). |
libp2p/transport/websocket/listener.py |
WebSocket listener now runs serve_websocket in an internally owned background system task/nursery. |
libp2p/transport/quic/listener.py |
QUIC listener no longer takes a nursery; prefers transport background nursery with owned-nursery fallback. |
libp2p/relay/circuit_v2/transport.py |
Remove nursery parameter from Circuit v2 listener listen(). |
libp2p/network/swarm.py |
Rename listener nursery concepts to a general background_nursery, wait on new event, and close listeners on shutdown. |
tests/utils/factories.py |
Update factory usage to call listen(maddr) without a nursery. |
tests/core/transport/test_tcp.py |
Update TCP tests to call listen(maddr) without a nursery. |
tests/core/transport/websocket/test_websocket.py |
Update WebSocket tests to call listen(maddr) without a nursery. |
tests/core/transport/websocket/test_websocket_integration.py |
Update Swarm event name used to await background nursery readiness. |
tests/core/transport/quic/test_listener.py |
Update QUIC listener tests to call listen(maddr) without a nursery. |
tests/core/transport/quic/test_integration.py |
Update QUIC integration tests to call listen(maddr) without a nursery. |
tests/core/io/test_trio_real_connection.py |
Update real TCP connection tests to call listen(maddr) without a nursery. |
newsfragments/726.breaking.rst |
Document the breaking API change and required caller updates. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ormat) - test_listener.py, test_websocket.py: remove now-unused `nursery` variable from `async with trio.open_nursery() as nursery:` blocks (ruff F841) — listen() no longer takes a nursery parameter - quic/listener.py: avoid re-declaring _owned_start_error with a type annotation in listen() (mypy no-redef); capture Event objects in local vars before entering the system-task closure so the `set()` calls resolve against non-Optional types (mypy union-attr) - tcp/tcp.py: ruff format reformatting Co-authored-by: Michael Eze <code.maestro64@gmail.com>
|
Thanks for the PR and the migration work here — overall this looks solid and checks pass locally. One note from review: the TCP repeated Blocking items from my side are mainly process:
Some questions:
Full review here: PR Review: #13081. Summary of Changes
2. Branch Sync Status and Merge ConflictsBranch Sync Status
Merge Conflict Analysis
3. Strengths
4. Issues FoundCritical
Major
# Reset state in case of a re-listen.
self._started = trio.Event()
self._stopped = trio.Event()
self._start_error = None
async def _run_server() -> None:
try:
async with trio.open_nursery() as nursery:
self._nursery = nursery
try:
started_listeners = await nursery.start(
serve_tcp,
handler,
tcp_port,
host_str,
)
self.listeners.extend(started_listeners)
except BaseException as error:
self._start_error = error
finally:
self._started.set()
finally:
self._stopped.set()
self._nursery = None
listener = transport.create_listener(handler)
assert len(listener.get_addrs()) == 0
result = await listener.listen(LISTEN_MADDR)
assert result is None
assert len(listener.get_addrs()) == 1
result = await listener.listen(LISTEN_MADDR)
assert result is None
assert len(listener.get_addrs()) == 2
transport = TCP()
listener = transport.create_listener(on_accept)
await listener.listen(LISTEN_MADDR)
local_conn = await transport.dial(listener.get_addrs()[0])
await ready.wait()
tcp_transport = TCP()
listener = tcp_transport.create_listener(tcp_stream_handler)
await listener.listen(LISTEN_MADDR)
listening_maddr = listener.get_addrs()[0]
conn_0 = await tcp_transport.dial(listening_maddr)
await event.wait()
assert conn_0 is not None and conn_1 is not NoneMinor
5. Security Review
6. Documentation and Examples
7. Newsfragment Requirement
8. Tests and Validation
9. Recommendations for Improvement
10. Questions for the Author
11. Overall Assessment
|
TCPListener used to reset per-call lifecycle state and open a fresh nursery on every listen(), orphaning earlier server tasks so close() could only cancel the last bind. Now the background system task and nursery are created once on first listen() and reused for subsequent binds. close() cancels them together, is idempotent, and listen() after close() raises OpenConnectionError. A trio.Lock serializes concurrent listen()/close() so the lazy spawn can't race. Adds test_tcp_listener_close_cancels_all_binds covering multi-bind teardown, idempotent close, and post-close rejection. Fixes the multi-bind regression acul71 flagged in the PR libp2p#1308 review. Renames the newsfragment to match issue libp2p#1314.
Tests that create TCPListener directly (test_trio_real_connection and raw_conn_factory) now call await listener.close() before letting the enclosing nursery cancel. This exercises the new listener-owns-its- lifetime contract and stops leaking listener tasks between tests. Addresses acul71's review on PR libp2p#1308.
|
Thanks @acul71 — all three blockers and the three questions are addressed. Pushed as two commits on top of the branch:
Major items1. async with self._lifecycle_lock:
if self._closed:
raise OpenConnectionError(f"Cannot listen on {maddr}: listener is closed")
if self._nursery is None:
await self._spawn_background_task() # first call only
...
started = await self._nursery.start(serve_tcp, handler, tcp_port, host_str)
self.listeners.extend(started)
2. Explicit
3. Linked issue + newsfragment naming. Opened #1314 to track the breaking change, updated the PR body with Your questions
Supported as contract — option (b) from your Major item.
Done — see commit 61bc430. New test await listener.listen(LISTEN_MADDR)
await listener.listen(LISTEN_MADDR)
assert len(listener.get_addrs()) == 2
await listener.close()
assert listener.get_addrs() == ()
await listener.close() # safe no-op
with pytest.raises(OpenConnectionError, match="listener is closed"):
await listener.listen(LISTEN_MADDR) # post-close rejected
Done — #1314. PR body now opens with Validation
Ready for another look when you have a moment. |
Summary
Fixes #1314 — removes the caller-supplied
trio.NurseryfromIListener.listen(). Listeners now own their background task lifetime internally and are cancelled viaclose(). See Discussion #726 (Issue 1) for context.Supersedes #1055. The original work by @codemaestro64 has been adopted, rebased on current main, reconciled with upstream drift, and extended. Every refactor commit carries
Co-authored-by: Michael Eze <code.maestro64@gmail.com>so authorship history is preserved.Why a new PR
PR #1055 has been sitting with merge conflicts since November 2025 and the author has not been active on it. This branch:
mainbool; main now returnsNoneand raisesOpenConnectionError)newsfragments/1314.breaking.rst)close()so callers observe a fully quiesced listener on returnApproach
Listeners spawn their internal nursery as a
trio.lowlevel.spawn_system_taskand track readiness via atrio.Event.close()cancels the scope and awaits a matching_stoppedevent so the background task has actually wound down before the call returns.TCPListenerowns a single long-lived nursery across repeatedlisten()calls — each additionallisten()schedules anothernursery.start(serve_tcp, ...)into the same nursery, soclose()cancels all binds atomically. A_closedflag rejects post-closelisten()calls withOpenConnectionError, and a_lifecycle_lockserializes concurrentlisten()/close()callers. This fixes the multi-bind lifecycle ambiguity @acul71 flagged in review.QUIC is a special case: when a transport-level background nursery is already configured (
set_background_nursery, the common path under Swarm), the listener piggybacks on it. Otherwise it uses the same system-task pattern.Files changed
libp2p/abc.py— dropnurseryfromIListener.listenlibp2p/transport/tcp/tcp.py— single long-lived internal nursery + system task, multi-bind lifecycle fixedlibp2p/transport/websocket/listener.py— same patternlibp2p/transport/quic/listener.py— prefer transport background nursery, fall back to self-spawnedlibp2p/relay/circuit_v2/transport.py— drop unused paramlibp2p/network/swarm.py— renamelistener_nurserytobackground_nursery(it was never only for listeners), calllistener.close()on shutdownawait listener.close()teardown in direct-listener tests and inraw_conn_factorynewsfragments/1314.breaking.rstTesting
pytest tests/core/transport/test_tcp.py tests/core/io/ tests/utils/ tests/core/network/ tests/core/security/— 373 passed, 2 skippedtest_tcp_listener_close_cancels_all_bindscovers multi-bind teardown, idempotentclose(), and post-closelisten()rejectionmake lint/make typecheckcleanReferences
nurseryparameter fromIListener.listen()(breaking) #1314Co-authored-by: