From 20a2d8f5d7fe05ec33ec84d85c8abf27ebe1bfe5 Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Tue, 30 Sep 2025 18:53:14 +0200 Subject: [PATCH] Send out PINGs ourselves to check if connection still up. --- src/ircplom/client.py | 30 +++++++++++----- src/ircplom/irc_conn.py | 26 +++++++++++--- src/ircplom/msg_parse_expectations.py | 22 ++++++++---- src/ircplom/testing.py | 30 +++++++++------- src/tests/pingpong.txt | 51 +++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 32 deletions(-) create mode 100644 src/tests/pingpong.txt diff --git a/src/ircplom/client.py b/src/ircplom/client.py index 7c21112..6569f8f 100644 --- a/src/ircplom/client.py +++ b/src/ircplom/client.py @@ -11,8 +11,9 @@ from typing import (Any, Callable, Collection, Generic, Iterable, Iterator, from ircplom.events import ( AffectiveEvent, CrashingException, ExceptionEvent, QueueMixin) from ircplom.irc_conn import ( - BaseIrcConnection, IrcConnAbortException, IrcMessage, - ILLEGAL_NICK_CHARS, ILLEGAL_NICK_FIRSTCHARS, ISUPPORT_DEFAULTS, PORT_SSL) + BaseIrcConnection, IrcConnAbortException, IrcConnException, + IrcConnTimeoutException, IrcMessage, ILLEGAL_NICK_CHARS, + ILLEGAL_NICK_FIRSTCHARS, ISUPPORT_DEFAULTS, PORT_SSL) from ircplom.msg_parse_expectations import MSG_EXPECTATIONS @@ -373,8 +374,7 @@ class IrcConnection(BaseIrcConnection, _ClientIdMixin): return ClientEvent.affector('handle_msg', client_id=self.client_id ).kw(msg=msg) - def _on_handled_loop_exception(self, e: IrcConnAbortException - ) -> 'ClientEvent': + def _on_handled_loop_exception(self, e: IrcConnException) -> 'ClientEvent': return ClientEvent.affector('on_handled_loop_exception', client_id=self.client_id).kw(e=e) @@ -817,6 +817,7 @@ class Client(ABC, ClientQueueMixin): 'Abstracts socket connection, loop over it, and handling messages from it.' conn: Optional[IrcConnection] = None _cls_conn: type[IrcConnection] = IrcConnection + _expected_pong = '' def __init__(self, conn_setup: IrcConnSetup, channels: set[str], **kwargs ) -> None: @@ -867,10 +868,20 @@ class Client(ABC, ClientQueueMixin): self.conn.close() self.conn = None - def on_handled_loop_exception(self, e: IrcConnAbortException) -> None: - 'Gracefully handle broken connection.' - self.db.connection_state = f'broken: {e}' - self.close() + def on_handled_loop_exception(self, e: IrcConnException) -> None: + 'On …AbortException, call .close(), on …Timeout… first (!) try PING.' + if isinstance(e, IrcConnAbortException): + self.db.connection_state = f'broken: {e}' + self.close() + elif isinstance(e, IrcConnTimeoutException): + if self._expected_pong: + self.on_handled_loop_exception( + IrcConnAbortException('no timely PONG from server')) + else: + self._expected_pong = "what's up?" + self.send('PING', self._expected_pong) + else: + raise e @abstractmethod def _on_update(self, *path) -> None: @@ -980,6 +991,9 @@ class Client(ABC, ClientQueueMixin): self.db.users.purge() elif ret['_verb'] == 'PING': self.send('PONG', ret['reply']) + elif ret['_verb'] == 'PONG': + assert self._expected_pong == ret['reply'] + self._expected_pong = '' elif ret['_verb'] == 'QUIT': ret['quitter'].quit(ret['message']) diff --git a/src/ircplom/irc_conn.py b/src/ircplom/irc_conn.py index fb1bd21..63b0aba 100644 --- a/src/ircplom/irc_conn.py +++ b/src/ircplom/irc_conn.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod from socket import socket, gaierror as socket_gaierror from ssl import create_default_context as create_ssl_context +from datetime import datetime from typing import Callable, Iterator, NamedTuple, Optional, Self # ourselves from ircplom.events import Event, Loop, QueueMixin @@ -10,6 +11,7 @@ from ircplom.events import Event, Loop, QueueMixin PORT_SSL = 6697 _TIMEOUT_RECV_LOOP = 0.1 +_TIMEOUT_PING = 120 _TIMEOUT_CONNECT = 5 _CONN_RECV_BUFSIZE = 1024 @@ -128,10 +130,18 @@ class IrcMessage: return self._raw -class IrcConnAbortException(BaseException): +class IrcConnException(BaseException): + 'Thrown by BaseIrcConnection on expectable connection issues.' + + +class IrcConnAbortException(IrcConnException): 'Thrown by BaseIrcConnection on expectable connection failures.' +class IrcConnTimeoutException(IrcConnException): + 'Thrown by BaseIrcConnection if recv timeout triggered.' + + class BaseIrcConnection(QueueMixin, ABC): 'Collects low-level server-client connection management.' @@ -167,19 +177,24 @@ class BaseIrcConnection(QueueMixin, ABC): pass @abstractmethod - def _on_handled_loop_exception(self, _: IrcConnAbortException) -> Event: + def _on_handled_loop_exception(self, _: IrcConnException) -> Event: pass def _read_lines(self) -> Iterator[Optional[Event]]: assert self._socket is not None bytes_total = b'' buffer_linesep = b'' + time_last_ping_check = datetime.now() try: while True: try: bytes_new = self._socket.recv(_CONN_RECV_BUFSIZE) - except TimeoutError: - yield None + except TimeoutError as e: + if (datetime.now() - time_last_ping_check).seconds\ + > _TIMEOUT_PING: + time_last_ping_check = datetime.now() + yield self._on_handled_loop_exception( + IrcConnTimeoutException(e)) continue except ConnectionResetError as e: raise IrcConnAbortException(e) from e @@ -189,6 +204,7 @@ class BaseIrcConnection(QueueMixin, ABC): raise e if not bytes_new: break + time_last_ping_check = datetime.now() for c in bytes_new: c_byted = c.to_bytes() if c not in _IRCSPEC_LINE_SEPARATOR: @@ -203,5 +219,5 @@ class BaseIrcConnection(QueueMixin, ABC): yield self._make_recv_event( IrcMessage.from_raw(bytes_total.decode('utf-8'))) bytes_total = b'' - except IrcConnAbortException as e: + except IrcConnException as e: yield self._on_handled_loop_exception(e) diff --git a/src/ircplom/msg_parse_expectations.py b/src/ircplom/msg_parse_expectations.py index e5605b9..461b85e 100644 --- a/src/ircplom/msg_parse_expectations.py +++ b/src/ircplom/msg_parse_expectations.py @@ -138,7 +138,7 @@ class _MsgParseExpectation: MSG_EXPECTATIONS: list[_MsgParseExpectation] = [ - # these we ignore except for confirming/collecting the nickname + # these we ignore except, where possible, for checking our nickname _MsgParseExpectation( '001', # RPL_WELCOME @@ -527,7 +527,7 @@ MSG_EXPECTATIONS: list[_MsgParseExpectation] = [ ((_MsgTok.CHANNEL, ':CHANNEL'), (_MsgTok.ANY, 'setattr_db.messaging.USER.to.CHANNEL:privmsg'))), - # misc. + # connection state _MsgParseExpectation( 'ERROR', @@ -535,6 +535,19 @@ MSG_EXPECTATIONS: list[_MsgParseExpectation] = [ ((_MsgTok.ANY, 'setattr_db:connection_state'),), bonus_tasks=('doafter_:close',)), + _MsgParseExpectation( + 'PING', + _MsgTok.NONE, + ((_MsgTok.ANY, ':reply'),)), + + _MsgParseExpectation( + 'PONG', + _MsgTok.SERVER, + (_MsgTok.SERVER, + (_MsgTok.ANY, ':reply'),)), + + # misc. + _MsgParseExpectation( 'MODE', (_MsgTok.NICK_USER_HOST, 'setattr_db.users.me:nickuserhost'), @@ -546,11 +559,6 @@ MSG_EXPECTATIONS: list[_MsgParseExpectation] = [ ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'), (_MsgTok.ANY, 'setattr_db.users.me:modes'))), - _MsgParseExpectation( - 'PING', - _MsgTok.NONE, - ((_MsgTok.ANY, ':reply'),)), - _MsgParseExpectation( 'TOPIC', (_MsgTok.NICK_USER_HOST, 'setattr_db.channels.CHAN.topic:who'), diff --git a/src/ircplom/testing.py b/src/ircplom/testing.py index 6ba0792..33c4eb5 100644 --- a/src/ircplom/testing.py +++ b/src/ircplom/testing.py @@ -6,7 +6,8 @@ from typing import Callable, Generator, Iterator, Optional from ircplom.events import Event, Loop, QueueMixin from ircplom.client import IrcConnection, IrcConnSetup from ircplom.client_tui import ClientKnowingTui, ClientTui, LOG_PREFIX_IN -from ircplom.irc_conn import IrcConnAbortException, IrcMessage +from ircplom.irc_conn import (IrcConnAbortException, IrcConnTimeoutException, + IrcMessage) from ircplom.tui_base import TerminalInterface, TuiEvent @@ -73,17 +74,22 @@ class _FakeIrcConnection(IrcConnection): pass def _read_lines(self) -> Iterator[Optional[Event]]: - while True: - try: - msg = self._q_server_msgs.get(timeout=0.1) - except QueueEmpty: - yield None - continue - if msg == 'FAKE_IRC_CONN_ABORT_EXCEPTION': - err = IrcConnAbortException(msg) - yield self._on_handled_loop_exception(err) - return - yield self._make_recv_event(IrcMessage.from_raw(msg)) + try: + while True: + try: + msg = self._q_server_msgs.get(timeout=0.1) + except QueueEmpty: + yield None + continue + if msg == 'FAKE_IRC_CONN_TIMEOUT_EXCEPTION': + yield self._on_handled_loop_exception( + IrcConnTimeoutException(msg)) + continue + if msg == 'FAKE_IRC_CONN_ABORT_EXCEPTION': + raise IrcConnAbortException(msg) + yield self._make_recv_event(IrcMessage.from_raw(msg)) + except IrcConnAbortException as e: + yield self._on_handled_loop_exception(e) class _TestClientKnowingTui(ClientKnowingTui): diff --git a/src/tests/pingpong.txt b/src/tests/pingpong.txt new file mode 100644 index 0000000..d0429bf --- /dev/null +++ b/src/tests/pingpong.txt @@ -0,0 +1,51 @@ +# + +# +> /connect foo.bar.baz foo:bar baz:foobarbazquux +1 .$ isupport cleared +1 .$ isupport:CHANTYPES set to: [#&] +1 .$ isupport:PREFIX set to: [(ov)@+] +1 .$ isupport:USERLEN set to: [10] +1 .$ caps cleared +1 .$ users cleared +1 .$ channels cleared +, .$ DISCONNECTED +1 .$ hostname set to: [foo.bar.baz] +1 .$ port set to: [-1] +1 .$ nick_wanted set to: [foo] +1 .$ user_wanted set to: [foobarbazquux] +1 .$ realname set to: [baz] +1 .$ password set to: [bar] +1 .$ port set to: [6697] +1 .$ connection_state set to: [connecting] +1 .$ connection_state set to: [connected] +, .$ CONNECTED +1 .> CAP LS :302 +1 .> USER foobarbazquux 0 * :baz +1 .> NICK :foo + +# ensure we PONG properly +0:1 .< PING :? +1 .> PONG :? + +# ping on timeout, go on as normal if PONG received +0: .< FAKE_IRC_CONN_TIMEOUT_EXCEPTION +1 .> PING :what's up? +0:1 .< :*.?.net PONG *.?.net :what's up? +0:1 .< :*.?.net NOTICE * :*** Looking up your ident... +2 .< *** [ server] *** Looking up your ident... + +# another timeout instead of pong? disconnect +0: .< FAKE_IRC_CONN_TIMEOUT_EXCEPTION +1 .> PING :what's up? +0: .< FAKE_IRC_CONN_TIMEOUT_EXCEPTION +1 .$ connection_state set to: [broken: no timely PONG from server] +1 .$ isupport cleared +1 .$ isupport:CHANTYPES set to: [#&] +1 .$ isupport:PREFIX set to: [(ov)@+] +1 .$ isupport:USERLEN set to: [10] +1 .$ connection_state set to: [] +2 .$ DISCONNECTED + +> /quit +0 .< -- 2.30.2