From 0c16ca07e3ac85ab212c3444f9e5a71c4a96a406 Mon Sep 17 00:00:00 2001 From: Kaliko Jack Date: Wed, 7 Jul 2021 20:31:14 +0200 Subject: [PATCH] Add socket timeout --- CHANGES.txt | 5 ++-- doc/source/doc.rst | 2 ++ doc/source/use.rst | 66 +++++++++++++++++++++++++++++++++++++++++++++- musicpd.py | 41 ++++++++++++++++++++++------ 4 files changed, 103 insertions(+), 11 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 71e92e2..0f1b22e 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,12 +1,13 @@ python-musicpd Changes List =========================== -Changes in 0.6.1 +Changes in 0.7.0 ---------------- +* Add socket timeout (disabled by default) +* MPD_TIMEOUT is set for both tcp and unix sockets * Raises an exception if command contains '\n' - Changes in 0.6.0 ---------------- diff --git a/doc/source/doc.rst b/doc/source/doc.rst index c270e12..6731e6b 100644 --- a/doc/source/doc.rst +++ b/doc/source/doc.rst @@ -3,6 +3,8 @@ musicpd namespace .. autodata:: musicpd.CONNECTION_TIMEOUT +.. autodata:: musicpd.SOCKET_TIMEOUT + .. autoclass:: musicpd.MPDClient :members: diff --git a/doc/source/use.rst b/doc/source/use.rst index f01fd39..cdf0ed2 100644 --- a/doc/source/use.rst +++ b/doc/source/use.rst @@ -35,7 +35,7 @@ The client honors the following environment variables: | For abstract socket use "@" as prefix : "`@socket`" and then with a password "`pass@@socket`" | Regular unix socket are set with an absolute path: "`/run/mpd/socket`" * ``MPD_PORT`` MPD port, relevant for TCP socket only, ie with :abbr:`FQDN (fully qualified domain name)` defined host - * ``MPD_TIMEOUT`` timeout for connecting to MPD and for waiting for MPD’s response in seconds + * ``MPD_TIMEOUT`` timeout for connecting to MPD and waiting for MPD’s response in seconds * ``XDG_RUNTIME_DIR`` path to look for potential socket: ``${XDG_RUNTIME_DIR}/mpd/socket`` Defaults settings @@ -172,4 +172,68 @@ You can also use `readpicture` command to fetch embedded picture: Refer to `MPD protocol documentation`_ for the meaning of `binary`, `size` and `data`. +Socket timeout +-------------- + +.. note:: + When the timeout is reached it raises a :py:obj:`socket.timeout` exception. An :py:obj:`OSError` subclass. + +A timeout is used for the initial MPD connection (``connect`` command), then +the socket is put in blocking mode with no timeout. Its value is set in +:py:obj:`musicpd.CONNECTION_TIMEOUT` at module level and +:py:obj:`musicpd.MPDClient.mpd_timeout` in MPDClient instances . However it +is possible to set socket timeout for all command setting +:py:obj:`musicpd.MPDClient.socket_timeout` attribute to a value in second. + +Having ``socket_timeout`` enabled can help to detect "half-open connection". +For instance loosing connectivity without the server explicitly closing the +connection (switching network interface ethernet/wifi, router down, etc…). + +**Nota bene**: with ``socket_timeout`` enabled each command sent to MPD might +timeout. A couple of seconds should be enough for commands to complete except +for the special case of ``idle`` command which by definition *“ waits until +there is a noteworthy change in one or more of MPD’s subsystems.”* (cf. `MPD +protocol documentation`_). + +Here is a solution to use ``idle`` command with ``socket_timeout``: + +.. code-block:: python + + import musicpd + import select + import socket + + cli = musicpd.MPDClient() + try: + cli.socket_timeout = 10 # seconds + select_timeout = 5 # second + cli.connect() + while True: + cli.send_idle() # use send_ API to avoid blocking on read + _read, _, _ = select.select([cli], [], [], select_timeout) + if _read: # tries to read response + ret = cli.fetch_idle() + print(', '.join(ret)) # Do something + else: # cancels idle + cli.noidle() + except socket.timeout as err: + print(f'{err} (timeout {cli.socket_timeout})') + except KeyboardInterrupt: + pass + +Some explanations: + + * First launch a non blocking ``idle`` command. This call do not wait for a + response to avoid socket timeout waiting for an MPD event. + * ``select`` waits for something to read on the socket (the idle response + in this case), returns after ``select_timeout`` seconds anyway. + * In case there is something to read read it using ``fetch_idle`` + * Nothing to read, cancel idle with ``noidle`` + +All three commands in the while loop (send_idle, fetch_idle, noidle) are not +triggering a socket timeout unless the connection is actually lost (actually it +could also be that MPD took to much time to answer, but MPD taking more than a +couple of seconds for these commands should never occur). + + .. _MPD protocol documentation: http://www.musicpd.org/doc/protocol/ diff --git a/musicpd.py b/musicpd.py index 68cd862..61ca97b 100644 --- a/musicpd.py +++ b/musicpd.py @@ -28,10 +28,12 @@ HELLO_PREFIX = "OK MPD " ERROR_PREFIX = "ACK " SUCCESS = "OK" NEXT = "list_OK" -VERSION = '0.6.1' -#: seconds before a tcp connection attempt times out (overriden by MPD_TIMEOUT env. var.) +VERSION = '0.7.0' +#: Seconds before a connection attempt times out +#: (overriden by MPD_TIMEOUT env. var.) CONNECTION_TIMEOUT = 30 - +#: Socket timeout in second (Default is None for no timeout) +SOCKET_TIMEOUT = None def iterator_wrapper(func): @@ -157,6 +159,11 @@ class MPDClient: def __init__(self): self.iterate = False + #: Socket timeout value in seconds + self._socket_timeout = SOCKET_TIMEOUT + #: Current connection timeout value, defaults to + #: :py:attr:`musicpd.MPD_TIMEOUT` or env. var. ``MPD_TIMEOUT`` if provided + self.mpd_timeout = None self._reset() self._commands = { # Status Commands @@ -324,7 +331,7 @@ class MPDClient: self.mpd_timeout = os.getenv('MPD_TIMEOUT') if self.mpd_timeout and self.mpd_timeout.isdigit(): self.mpd_timeout = int(self.mpd_timeout) - else: # Use 30s default even is MPD_TIMEOUT carries gargage + else: # Use CONNECTION_TIMEOUT as default even if MPD_TIMEOUT carries gargage self.mpd_timeout = CONNECTION_TIMEOUT def __getattr__(self, attr): @@ -611,7 +618,9 @@ class MPDClient: if path.startswith('@'): path = '\0'+path[1:] sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(self.mpd_timeout) sock.connect(path) + sock.settimeout(self.socket_timeout) return sock def _connect_tcp(self, host, port): @@ -629,7 +638,7 @@ class MPDClient: sock = socket.socket(af, socktype, proto) sock.settimeout(self.mpd_timeout) sock.connect(sa) - sock.settimeout(None) + sock.settimeout(self.socket_timeout) return sock except socket.error as socket_err: err = socket_err @@ -658,14 +667,18 @@ class MPDClient: The connect method honors MPD_HOST/MPD_PORT environment variables. - The underlying tcp socket also honors MPD_TIMEOUT environment variable - and defaults to :py:obj:`musicpd.CONNECTION_TIMEOUT`. + The underlying socket also honors MPD_TIMEOUT environment variable + and defaults to :py:obj:`musicpd.CONNECTION_TIMEOUT` (connect command only). + + If you want to have a timeout for each command once you got connected, + set its value in :py:obj:`MPDClient.socket_timeout` (in second) or at + module level in :py:obj:`musicpd.SOCKET_TIMEOUT`. .. note:: Default host/port If host evaluate to :py:obj:`False` * use ``MPD_HOST`` environment variable if set, extract password if present, - * else looks for a existing file in ``${XDG_RUNTIME_DIR:-/run/}/mpd/socket`` + * else looks for an existing file in ``${XDG_RUNTIME_DIR:-/run/}/mpd/socket`` * else set host to ``localhost`` If port evaluate to :py:obj:`False` @@ -695,6 +708,18 @@ class MPDClient: self.disconnect() raise + @property + def socket_timeout(self): + """Socket timeout in second (defaults to :py:obj:`SOCKET_TIMEOUT`). + Use None to disable socket timout.""" + return self._socket_timeout + + @socket_timeout.setter + def socket_timeout(self, timeout): + self._socket_timeout = timeout + if getattr(self._sock, 'settimeout', False): + self._sock.settimeout(self._socket_timeout) + def disconnect(self): """Closes the MPD connection. The client closes the actual socket, it does not use the -- 2.39.2