mavutil: fix mavtcp.recv() crashing when autoreconnect is disabled - #1246
mavutil: fix mavtcp.recv() crashing when autoreconnect is disabled#1246khancyr wants to merge 2 commits into
Conversation
peterbarker
left a comment
There was a problem hiding this comment.
Please commit the test
463d734 to
2afad2c
Compare
|
@peterbarker update with the fd closing and some tests |
|
Unfortunately this does appear to break some things: Perhaps we do need to rethink setting that fd to None in this branch and do it separately? |
08c90ea to
93a8a80
Compare
close() called self.port.close() unconditionally, so closing a mavtcp whose connect had already failed (do_connect() nulls self.port after exhausting its retries) raised AttributeError, and closing twice raised on the second call. Guard it and null the port, so close() is idempotent and leaves the object in a state the rest of the class already tests for. recv() then needs to say something sensible when it finds self.port None. It calls reconnect() first, but reconnect() is a no-op unless autoreconnect=True, so the socket can still be gone when it returns, and the next self.port.recv(n) raised AttributeError. Returning b"" would be the smaller change, but it puts a permanent condition in a transient bucket. recv() already uses b"" to mean "no data right now, retry" - that is the EAGAIN/EWOULDBLOCK path - and already raises for a link that is actually broken, since ECONNRESET/EPIPE calls handle_disconnect() and then re-raises. A socket that is gone and has nothing to reopen it belongs with the second group, not the first. Answering b"" there makes recv_msg() return None forever and recv_match(blocking=True) spin at the select() cadence with nothing ever reported. So raise OSError(errno.ENOTCONN, "TCP socket is closed"). ENOTCONN is not in CPython's errno-to-subclass map, so this stays a plain OSError with errno set - which matters because every decision in this class is made by inspecting e.errno, and a bare ConnectionError carries None. It also remains catchable by the existing `except OSError` / `except socket.error` handlers in callers. Both entry points now go through _ensure_port(), so there is one place that decides whether a socket is usable and one exception type for "it is not". write() keeps its existing contract by catching that and returning: its callers are sending a mavlink message through self.mav and are not in a position to handle a dead link. That subsumes the try/except socket.error it used to wrap reconnect() in, and makes the silence deliberate rather than incidental. A failed reconnect still surfaces its own, more specific error - verified as ConnectionRefusedError (errno 111) rather than being flattened to ENOTCONN. Behaviour change worth noting for callers: recv() after close() now raises where it previously would have dereferenced None. Anything closing a link from another thread or a signal handler to break out of a blocking recv_match() will see OSError instead of an exception about NoneType. With autoreconnect=False, close() is the only way to reach this state - reconnect() is guarded by `if self.autoreconnect:` and returns without touching the port, and do_connect() only nulls it when it is about to raise - so this fires precisely when a closed connection is used. Deliberately not clearing self.fd here, as suggested during review. mavtcp only ever assigns self.fd in mavfile.__init__ - neither do_connect() nor reconnect() maintains it - so clearing it on close() leaves a reconnected connection with fd=None, and mavfile.select() then takes its "no fd" branch and sleeps 0.5s claiming readiness instead of waiting on the socket. Measured: select(1.0) returned True after 0.500s. Making fd consistent needs do_connect() to set it, and wants doing across the other mavfile subclasses too, so it is left for a separate change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers what the previous commit changes: fd and port after construction, close() being idempotent, recv()/write()/select() after close(), a data round-trip, and a reconnect on the autoreconnect=True path. recv() after close() is asserted to raise OSError with errno ENOTCONN, and to not be a ConnectionError - a bare ConnectionError would carry errno None, which is useless to the callers most likely to catch it. write() after close() is asserted to stay silent, since it keeps its fire-and-forget contract. Points raised in review and addressed here: - the port is obtained by binding tcpin to port 0 and reading back what the OS assigned, rather than probing for a free port and closing the probe socket before rebinding it, which left a window for something else to take it. - the data round-trip drives the listener through mavtcpin's own API - recv() is what performs the accept, and write() sends to the accepted socket - rather than reaching past it into listen.accept(). Since the listening socket is non-blocking, recv() is pumped against a deadline instead of called once. - the receive loop runs against a deadline with a sleep between attempts, rather than spending a fixed 50 iterations that could all be consumed before loopback data became readable. - test_recv_when_port_is_none previously carried a comment claiming reconnect() nulls self.port on disconnect. It does not - the body is guarded by `if self.autoreconnect:`. Relabelled as what it is: a unit test of the guard itself, with close() covered separately as the reachable route. - added autoreconnect=True coverage, which was missing entirely. That test uses a plain socket listener instead of mavtcpin: it needs two successive server-side connections, and mavtcpin tracks only one at a time and re-accepts only after its own recv() hits an error. settimeout() lets accept() block up to the timeout, so it needs no polling. No assertion is made about self.fd after close(), since mavtcp does not maintain it across close()/do_connect(); that is left for the separate change that fixes it rather than pinned here. 25 consecutive runs of this file are clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
93a8a80 to
47123ac
Compare
peterbarker
left a comment
There was a problem hiding this comment.
Sorry, I think we also need a test for the "deliberately closed" cases.
| pass | ||
| if self.port is None: | ||
| try: | ||
| self._ensure_port() |
There was a problem hiding this comment.
Problem here is that we haven't checked whether we intentionally closed the port.
It only kind of worked by accident as it was, but now if you've marked the port as automatically-reconnect then when we go through this path we will reconnect even if the thing was closed on purpose.
We probably need some self._closed state to fix this.
There was a problem hiding this comment.
I have https://github.com/khancyr/pymavlink/tree/fix/mavtcp-close-is-final as follow up. I just didn't play with it yet ...
recv() called self.reconnect() when self.port was None, but reconnect() is a no-op unless autoreconnect=True was passed to the constructor. With the default autoreconnect=False, a lost connection left self.port as None and the very next self.port.recv(n) call crashed with AttributeError instead of a clear "no data" result.
write() already has this same guard a few lines down (checks self.port is None after attempting reconnect and returns early); recv() was just missing its equivalent.
Now returns b"" (matching the existing "no data available right now" convention on the EAGAIN/EWOULDBLOCK exception path a few lines below) when the socket is still unset after the reconnect attempt.
Done with Claude.
tested with