Skip to content

Commit bf24d14

Browse files
fix: every surface raises the error that says what happened
The split into ChannelClosed, GatewayGone and ExecnetStateError reached only the sync channel. The async core behind execnet.trio, execnet.aio and execnet.raw_trio still raised a bare OSError for the same events, so the names those namespaces export were never raised there. Connection gone: sends on a closed channel are ChannelClosed, sends and opens on a finished connection GatewayGone, and a write that times out is execnet.TimeoutError. The reader and writer store a lost connection as GatewayGone, which is what receive raises on every surface. They also stored the backend's own ClosedResource/BrokenResource before, which receive then raised as-is; it is kept only as the cause now. Misuse: a group used before start or started twice, blocking inside a running event loop or on the engine's own loop thread, and using a closed engine raise ExecnetStateError instead of plain RuntimeError, which it subclasses. Registering a second callback moves from OSError to ExecnetStateError, the same break as the two channel misuse cases. Environment and internal-invariant errors stay RuntimeError: a missing uv, a monkey-patched process, an engine that cannot start. So does the deprecated RSync adapter's OSError, which keeps its 2.x behaviour. Five of the six new tests fail against the previous code; the sixth (sync send after close) was already ChannelClosed. Co-Authored-By: Claude Opus 5.5 (1M context) via Claude Code <noreply@anthropic.com>
1 parent 171032c commit bf24d14

14 files changed

Lines changed: 169 additions & 46 deletions

‎CHANGELOG.rst‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -210,13 +210,22 @@ series, once the consumers that need them have released without them.
210210
keeps working -- pytest-xdist swallows exactly that around its shutdown send.
211211
``GatewayGone`` is *also* an ``EOFError``, because that is how a broken
212212
connection has always surfaced; the distinction becomes available without
213-
anything ceasing to be caught.
214-
215-
One deliberate break: two channel operations raised ``OSError`` for API
216-
misuse -- closing a channel inside its own ``remote_exec``, and calling
217-
``receive()`` on a channel that has a callback registered. Both are
218-
``ExecnetStateError`` now, which is a ``RuntimeError`` and *not* an
219-
``OSError``. That is the point of the split.
213+
anything ceasing to be caught. A connection that is lost rather than
214+
terminated is reported as ``GatewayGone`` by ``receive`` too, on every
215+
surface -- ``execnet``, ``execnet.gevent``, ``execnet.trio``,
216+
``execnet.aio`` and ``execnet.raw_trio`` alike.
217+
218+
``ExecnetStateError`` is what calling something at the wrong time or place
219+
raises: using a group that was never started or started twice, blocking
220+
from inside a running event loop or from the engine's own loop thread, or
221+
using an engine after it was closed. These were plain ``RuntimeError``,
222+
which ``ExecnetStateError`` subclasses, so nothing that catches them changes.
223+
224+
One deliberate break: three channel operations raised ``OSError`` for API
225+
misuse -- closing a channel inside its own ``remote_exec``, calling
226+
``receive()`` on a channel that has a callback registered, and registering
227+
a second callback. All three are ``ExecnetStateError`` now, which is a
228+
``RuntimeError`` and *not* an ``OSError``. That is the point of the split.
220229

221230
* **What crosses a channel is typed.** Values sent and received were ``object``
222231
and ``Any``; they now name execnet's wire format -- ``None``, ``bool``,

‎src/execnet/_bridge.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141

4242
from ._async import current_async
4343
from ._async import for_backend
44+
from ._errors import ExecnetStateError
4445
from ._errors import LoopFinishedError
4546
from ._trio_gateway import AsyncGroup as _TrioGroup
4647

@@ -276,7 +277,7 @@ async def runner() -> None:
276277
result = await async_fn(*args)
277278
except self._aio.Cancelled:
278279
# engine shutdown: the nursery cancel must propagate
279-
carrier.resolve(None, RuntimeError(ENGINE_GONE))
280+
carrier.resolve(None, ExecnetStateError(ENGINE_GONE))
280281
raise
281282
except BaseException as exc:
282283
carrier.resolve(None, exc)
@@ -289,14 +290,14 @@ def spawn() -> None:
289290
try:
290291
self._engine.start_soon(runner)
291292
except BaseException as exc:
292-
error = RuntimeError(ENGINE_GONE)
293+
error = ExecnetStateError(ENGINE_GONE)
293294
error.__cause__ = exc
294295
carrier.resolve(None, error)
295296

296297
try:
297298
self._engine.portal.post(spawn)
298299
except LoopFinishedError:
299-
raise RuntimeError("the execnet engine is not running") from None
300+
raise ExecnetStateError("the execnet engine is not running") from None
300301

301302
def cancel_engine_side() -> None:
302303
with suppress(LoopFinishedError):

‎src/execnet/_deploy/_facade.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from typing import TypeVar
1919

2020
from .._engine import check_not_in_event_loop
21+
from .._errors import GatewayGone
2122
from .._services import ServiceTarget
2223

2324
if TYPE_CHECKING:
@@ -49,7 +50,7 @@ def run_blocking(
4950

5051
sessions = [gateway._trio_session for gateway in gateways]
5152
if any(session is None for session in sessions):
52-
raise OSError("a gateway with no connection cannot be worked on")
53+
raise GatewayGone("a gateway with no connection cannot be worked on")
5354
engines = {id(session.engine) for session in sessions}
5455
if len(engines) > 1:
5556
raise ValueError(

‎src/execnet/_engine.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from typing import Any
3636

3737
from ._errors import ActiveGroupsWarning
38+
from ._errors import ExecnetStateError
3839
from ._errors import forked_error
3940

4041
if TYPE_CHECKING:
@@ -152,7 +153,7 @@ def check_not_in_event_loop(what: str) -> None:
152153
loop = _running_event_loop()
153154
if loop is None:
154155
return
155-
raise RuntimeError(
156+
raise ExecnetStateError(
156157
f"{what} blocks the calling thread and you are inside a running"
157158
f" {loop} event loop, which would stall every task on it."
158159
f" Use {_SURFACE_FOR_LOOP[loop]} instead, or run this in a"
@@ -246,7 +247,7 @@ def _ensure_started(self) -> Any:
246247
"""The started :class:`~execnet._trio_engine.TrioEngine` (internal)."""
247248
with self._lock:
248249
if self._closed:
249-
raise RuntimeError(
250+
raise ExecnetStateError(
250251
f"{self!r} was closed: the loop thread is gone, and with it"
251252
" every gateway and channel it served. Closing is final --"
252253
" build a new ProtocolEngine (and a new Group on it)"
@@ -338,7 +339,7 @@ def _check_not_engine_thread(self, what: str) -> None:
338339
"""Refuse an operation that would have the loop wait for itself."""
339340
trio_engine = self._trio_engine
340341
if trio_engine is not None and trio_engine._on_engine_thread():
341-
raise RuntimeError(
342+
raise ExecnetStateError(
342343
f"{what} was called from {self!r}'s own loop thread, where it"
343344
" would wait for that loop to finish work it is itself"
344345
" running. Call it from the thread that owns the engine."

‎src/execnet/_gateway_base.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ def _start_channel_consumer(
135135
"""
136136
session = self._trio_session
137137
if session is None:
138-
raise OSError(f"cannot set callback on {channel!r}: no active session")
138+
raise GatewayGone(f"cannot set callback on {channel!r}: no active session")
139139
session.attach_consumer(channel, callback, endmarker)
140140

141141
def _terminate_execution(self) -> None:

‎src/execnet/_portal.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from ._boundary import OneShot
3636
from ._boundary import ThreadWakener
3737
from ._boundary import Wakener
38+
from ._errors import ExecnetStateError
3839
from ._errors import LoopFinishedError
3940
from ._errors import forked_error
4041

@@ -164,7 +165,7 @@ def run(self, async_fn: Callable[..., Awaitable[T]], *args: Any) -> T:
164165
"""Run ``await async_fn(*args)`` on the loop, blocking this thread."""
165166
self._check_process()
166167
if self.is_loop_thread():
167-
raise RuntimeError(
168+
raise ExecnetStateError(
168169
"this is a blocking function; call it from a thread that is"
169170
" not running this loop"
170171
)

‎src/execnet/_services.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from typing import TYPE_CHECKING
3434
from typing import Any
3535

36+
from ._errors import GatewayGone
3637
from ._message import Message
3738
from ._serialize import dumps_internal
3839

@@ -140,7 +141,7 @@ def from_sync(cls, gateway: Any) -> ServiceTarget:
140141
"""A target for a blocking-surface ``Gateway``."""
141142
session = gateway._trio_session
142143
if session is None:
143-
raise OSError(f"{gateway!r} has no connection to run a service on")
144+
raise GatewayGone(f"{gateway!r} has no connection to run a service on")
144145
return cls(session, gateway._channelfactory.allocate_id)
145146

146147
def __repr__(self) -> str:

‎src/execnet/_trio_gateway.py‎

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@
4848

4949
from ._xspec import XSpec
5050

51+
from ._errors import ChannelClosed
52+
from ._errors import ExecnetStateError
53+
from ._errors import GatewayGone
5154
from ._errors import GatewayReceivedTerminate
5255
from ._errors import HostNotFound
5356
from ._errors import RemoteError
@@ -89,6 +92,17 @@ async def provision_sync(fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
8992
return result
9093

9194

95+
def _gone(message: str, cause: BaseException) -> GatewayGone:
96+
"""The connection ended: what a receive or send on it reports.
97+
98+
The backend's own exception stays attached as the cause, but never
99+
reaches a caller as the error itself.
100+
"""
101+
error = GatewayGone(message)
102+
error.__cause__ = cause
103+
return error
104+
105+
92106
class ByteStream(Protocol):
93107
"""Neutral bidirectional byte-stream protocol for gateway transports.
94108
@@ -300,7 +314,7 @@ async def send_bytes(self, data: bytes) -> None:
300314
the sync ``Channel.send`` contract.
301315
"""
302316
if self._closed or self._sent_eof:
303-
raise OSError(f"cannot send to {self!r}")
317+
raise ChannelClosed(f"cannot send to {self!r}")
304318
await self.gateway._send(Message.CHANNEL_DATA, self.id, data)
305319

306320
async def receive_bytes(self) -> bytes:
@@ -318,7 +332,7 @@ async def receive_bytes(self) -> bytes:
318332
async def send_eof(self) -> None:
319333
"""Signal that no more payloads follow (peer keeps its send side)."""
320334
if self._closed or self._sent_eof:
321-
raise OSError(f"cannot send EOF to {self!r}")
335+
raise ChannelClosed(f"cannot send EOF to {self!r}")
322336
self._sent_eof = True
323337
await self.gateway._send(Message.CHANNEL_LAST_MESSAGE, self.id)
324338

@@ -501,7 +515,7 @@ async def send(self, item: SendPayload) -> None:
501515
channel or gateway is closed.
502516
"""
503517
if self.isclosed():
504-
raise OSError(f"cannot send to {self!r}")
518+
raise ChannelClosed(f"cannot send to {self!r}")
505519
await self._raw.send_bytes(dumps_internal(item))
506520

507521
async def receive(self, timeout: float | None = None) -> Payload[AsyncChannel]:
@@ -628,7 +642,7 @@ def _open_raw_channel(self, id: int | None = None) -> RawChannel:
628642
dispatch loop already routed data to it.
629643
"""
630644
if self._closed:
631-
raise OSError(f"connection already closed: {self!r}")
645+
raise GatewayGone(f"connection already closed: {self!r}")
632646
if id is None:
633647
id = self._count
634648
self._count += 2
@@ -734,14 +748,12 @@ async def _reader(self) -> None:
734748
# callers test for EOFError, and an endmarker callback
735749
# must fire either way.
736750
if not self._closed:
737-
error = EOFError(f"connection closed: {exc}")
738-
error.__cause__ = exc
739-
self._error = error
751+
self._error = _gone(f"connection closed: {exc}", exc)
740752
return
741753
except self._aio.ClosedResource as exc:
742754
# we closed it; not the peer going away
743755
if not self._closed:
744-
self._error = exc
756+
self._error = _gone("connection closed by this side", exc)
745757
return
746758
if not data:
747759
decoder.close() # raises EOFError on a mid-frame EOF
@@ -753,7 +765,7 @@ async def _reader(self) -> None:
753765
self._trace("received GATEWAY_TERMINATE")
754766
except EOFError as exc:
755767
self._trace("EOF without prior gateway termination message")
756-
self._error = exc
768+
self._error = exc if isinstance(exc, GatewayGone) else _gone(str(exc), exc)
757769

758770
async def _writer(self) -> None:
759771
error: BaseException | None = None
@@ -771,7 +783,7 @@ async def _writer(self) -> None:
771783
except (*self._aio.STREAM_GONE, OSError) as exc:
772784
self._trace("writer failed", exc)
773785
if self._error is None:
774-
self._error = exc
786+
self._error = _gone(f"connection lost while writing: {exc}", exc)
775787
else:
776788
# Queue closed and drained: signal write-EOF to the peer.
777789
with suppress(Exception):
@@ -784,7 +796,9 @@ async def _writer(self) -> None:
784796
self._writer_done.set()
785797

786798
def _fail_pending_writes(self, error: BaseException | None) -> None:
787-
exc = error if error is not None else OSError("cannot send (already closed?)")
799+
exc = (
800+
error if error is not None else GatewayGone("cannot send (already closed?)")
801+
)
788802
while True:
789803
try:
790804
_frame, on_written = self._outbound.receive_nowait()
@@ -884,7 +898,7 @@ async def _send(self, msgcode: int, channelid: int = 0, data: bytes = b"") -> No
884898
(Message(msgcode, channelid, data).pack(), None)
885899
)
886900
except self._aio.STREAM_GONE as exc:
887-
raise OSError("cannot send (already closed?)") from exc
901+
raise GatewayGone("cannot send (already closed?)") from exc
888902

889903
def _enqueue_frame(
890904
self,
@@ -900,7 +914,7 @@ def _enqueue_frame(
900914
try:
901915
self._outbound_send.send_nowait((frame, on_written))
902916
except self._aio.STREAM_GONE as exc:
903-
raise OSError("cannot send (already closed?)") from exc
917+
raise GatewayGone("cannot send (already closed?)") from exc
904918

905919
def _send_nowait(self, msgcode: int, channelid: int = 0, data: bytes = b"") -> None:
906920
"""Enqueue a frame from a dispatch handler (sync, inline on the loop)."""
@@ -1369,7 +1383,7 @@ async def makegateway(self, spec: str | XSpec = "popen") -> AsyncGateway:
13691383
from ._xspec import XSpec
13701384

13711385
if self._scope is None:
1372-
raise RuntimeError(f"{self!r} is not entered")
1386+
raise ExecnetStateError(f"{self!r} is not entered")
13731387
if not isinstance(spec, XSpec):
13741388
spec = XSpec(spec)
13751389
if spec.profile is None:

‎src/execnet/_trio_host.py‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,12 @@
2424
from ._channel import ENDMARKER
2525
from ._channel import NO_ENDMARKER_WANTED
2626
from ._channel import Endmarker
27+
from ._errors import ExecnetStateError
28+
from ._errors import GatewayGone
2729
from ._errors import GatewayReceivedTerminate
2830
from ._errors import LoopFinishedError
2931
from ._errors import RemoteError
32+
from ._errors import TimeoutError
3033
from ._execmodel import ExecModel
3134
from ._execmodel import get_execmodel
3235
from ._message import FrameDecoder
@@ -337,13 +340,13 @@ def switch() -> None:
337340
# Fail before touching the channel -- a half-switched channel
338341
# loses its buffered items, refuses receive(), and leaves
339342
# waitclose() waiting for a consumer that will never run.
340-
raise OSError(
343+
raise GatewayGone(
341344
f"cannot set callback on {channel!r}: the engine loop has"
342345
" stopped, so nothing can deliver to it"
343346
)
344347
mailbox = channel._mailbox
345348
if mailbox is None:
346-
raise OSError(f"{channel!r} has callback already registered")
349+
raise ExecnetStateError(f"{channel!r} has callback already registered")
347350
inbox_send, inbox_recv = self._aio.queue()
348351

349352
def feed(data: bytes) -> None:
@@ -488,21 +491,21 @@ def post() -> None:
488491

489492
with self._send_lock:
490493
if self._send_closed:
491-
raise OSError("cannot send (already closed?)")
494+
raise GatewayGone("cannot send (already closed?)")
492495
try:
493496
# Through the portal even from the engine thread so every
494497
# send lands in one global FIFO order.
495498
self.engine.portal.post(post)
496499
except LoopFinishedError:
497-
raise OSError("cannot send (already closed?)") from None
500+
raise GatewayGone("cannot send (already closed?)") from None
498501
if ack is None:
499502
return
500503
try:
501504
error = ack.wait(timeout=120.0)
502505
except TimeoutError:
503-
raise OSError("cannot send (write timed out)") from None
506+
raise TimeoutError("cannot send (write timed out)") from None
504507
if error is not None:
505-
raise OSError("cannot send (already closed?)") from error
508+
raise GatewayGone("cannot send (already closed?)") from error
506509

507510
def post_message(self, message: Message) -> None:
508511
"""Best-effort non-waiting send (Channel.__del__ during GC)."""
@@ -515,7 +518,7 @@ def post() -> None:
515518
try:
516519
self.engine.portal.post(post)
517520
except LoopFinishedError:
518-
raise OSError("cannot send (already closed?)") from None
521+
raise GatewayGone("cannot send (already closed?)") from None
519522

520523
def request_close_write(self) -> None:
521524
with self._send_lock:

‎src/execnet/aio.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ def engine(self) -> ProtocolEngine:
267267
async def start(self) -> None:
268268
"""Bring the engine up and start the group task on it."""
269269
if self._group is not None:
270-
raise RuntimeError(f"{self!r} is already started")
270+
raise ExecnetStateError(f"{self!r} is already started")
271271
trio_engine = await start_engine(self._engine, AsyncioCarrier())
272272
bridge = AsyncioBridge(trio_engine)
273273

@@ -315,7 +315,7 @@ async def makegateway(self, spec: str | XSpec = "popen") -> AsyncGateway:
315315
"""
316316
group, bridge = self._group, self._bridge
317317
if group is None or bridge is None:
318-
raise RuntimeError(f"{self!r} is not started")
318+
raise ExecnetStateError(f"{self!r} is not started")
319319
gateway = await bridge.call(group.makegateway, spec)
320320
return AsyncGateway(bridge, gateway)
321321

0 commit comments

Comments
 (0)