home · contact · privacy
Send out PINGs ourselves to check if connection still up.
authorChristian Heller <c.heller@plomlompom.de>
Tue, 30 Sep 2025 16:53:14 +0000 (18:53 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Tue, 30 Sep 2025 16:53:14 +0000 (18:53 +0200)
src/ircplom/client.py
src/ircplom/irc_conn.py
src/ircplom/msg_parse_expectations.py
src/ircplom/testing.py
src/tests/pingpong.txt [new file with mode: 0644]

index 7c21112e693953584375e27ade9590b92b53d499..6569f8f0bf2e7345765d6749fd197017c9bf791c 100644 (file)
@@ -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'])
 
index fb1bd21d973d4fbe42b93688f40035b31e20f0ee..63b0abac89729d406c86caa6af3f234658729cf1 100644 (file)
@@ -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)
index e5605b9a10bf200f7566549edce26d81551213c5..461b85e9956b7ed866fb6e88d5366ef52209f9cd 100644 (file)
@@ -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'),
index 6ba0792bf6d7c59e64fcf00150c8931258d84fb7..33c4eb5b097d879ab81b950202f6098bb3d1c948 100644 (file)
@@ -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 (file)
index 0000000..d0429bf
--- /dev/null
@@ -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 .<