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
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)
'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:
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:
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'])
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
PORT_SSL = 6697
_TIMEOUT_RECV_LOOP = 0.1
+_TIMEOUT_PING = 120
_TIMEOUT_CONNECT = 5
_CONN_RECV_BUFSIZE = 1024
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.'
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
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:
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)
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
((_MsgTok.CHANNEL, ':CHANNEL'),
(_MsgTok.ANY, 'setattr_db.messaging.USER.to.CHANNEL:privmsg'))),
- # misc.
+ # connection state
_MsgParseExpectation(
'ERROR',
((_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'),
((_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'),
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
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):
--- /dev/null
+#
+
+#
+> /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 .<