Aiohttp: El bucle de mensajes de lectura de Websocket genera un error cancelado de bajo nivel cuando la conexión se cierra inesperadamente

Creado en 6 jul. 2017  ·  4Comentarios  ·  Fuente: aio-libs/aiohttp

Comportamiento real

La lectura del bucle de mensajes async for msg in ws: aumenta el nivel bajo concurrent.futures._base.CancelledError cuando la conexión se cierra inesperadamente.

Comportamiento esperado

Se esperaba recibir un mensaje con el tipo aiohtto.http_websocket.WSMsgType.ERROR , o detener silenciosamente el bucle, o al menos aiohtto.http_websocket.WebSocketError .

pasos para reproducir

Ejecute los siguientes dos scripts server.py y client.py , luego detenga client.py por Ctrl+C .

server.py

import logging

from aiohttp import web


logger = logging.getLogger(__name__)


async def index(request):
    ws = web.WebSocketResponse()
    request.app['websockets'].add(ws)

    try:
        await ws.prepare(request)
        logger.debug('Connected')
        async for msg in ws:
            logger.info('Received: %r', msg.data)
    except Exception:
        logger.exception('Error')
    logger.debug('Disconnected')

    request.app['websockets'].discard(ws)
    return ws


async def on_shutdown(app):
    for ws in app['websockets']:
        await ws.close()
    app['websockets'].clear()


def main():
    logging.basicConfig(level=logging.DEBUG)

    app = web.Application()
    app['websockets'] = set()
    app.router.add_get('/', index)
    app.on_shutdown.append(on_shutdown)

    web.run_app(app, host='127.0.0.1', port=9000)


if __name__ == '__main__':
    main()

client.py

import asyncio

import aiohttp


async def communicate(loop):
    async with aiohttp.ClientSession(loop=loop) as session:
        async with session.ws_connect('http://127.0.0.1:9000') as ws:
            while True:
                await ws.send_str('Hello')
                await asyncio.sleep(1, loop=loop)


def main():
    loop = asyncio.get_event_loop()
    loop.run_until_complete(communicate(loop))


if __name__ == '__main__':
    main()

Salida de registro de server.py

$ python server.py 
DEBUG:asyncio:Using selector: EpollSelector
======== Running on http://127.0.0.1:9000 ========
(Press CTRL+C to quit)
DEBUG:__main__:Connected
INFO:__main__:Received: 'Hello'
INFO:__main__:Received: 'Hello'
INFO:__main__:Received: 'Hello'
INFO:__main__:Received: 'Hello'
INFO:__main__:Received: 'Hello'
INFO:__main__:Received: 'Hello'
INFO:__main__:Received: 'Hello'
INFO:__main__:Received: 'Hello'
INFO:__main__:Received: 'Hello'
ERROR:__main__:Error
Traceback (most recent call last):
  File "server.py", line 16, in index
    async for msg in ws:
  File "/home/vagrant/project/workspace/pyenv_dev/lib64/python3.5/site-packages/aiohttp/web_ws.py", line 343, in __anext__
    msg = yield from self.receive()
  File "/home/vagrant/project/workspace/pyenv_dev/lib64/python3.5/site-packages/aiohttp/web_ws.py", line 273, in receive
    msg = yield from self._reader.read()
  File "/home/vagrant/project/workspace/pyenv_dev/lib64/python3.5/site-packages/aiohttp/streams.py", line 627, in read
    return (yield from super().read())
  File "/home/vagrant/project/workspace/pyenv_dev/lib64/python3.5/site-packages/aiohttp/streams.py", line 509, in read
    yield from self._waiter
  File "/usr/lib64/python3.5/asyncio/futures.py", line 380, in __iter__
    yield self  # This tells Task to wait for completion.
  File "/usr/lib64/python3.5/asyncio/tasks.py", line 304, in _wakeup
    future.result()
  File "/usr/lib64/python3.5/asyncio/futures.py", line 285, in result
    raise CancelledError
concurrent.futures._base.CancelledError
DEBUG:__main__:Disconnected
INFO:aiohttp.access:- - - [06/Jul/2017:11:41:25 +0000] "GET / HTTP/1.1" 101 0 "-" "Python/3.5 aiohttp/2.2.3"

Tu entorno

SO: CentOS Linux 7
Kernel de Linux: 3.10.0-514.16.1.el7.x86_64
Python: 3.5.3
aiohttp: 2.2.3

Comentario más útil

¿Quizás es mejor introducir una excepción ConnectionClosed separada, de la misma manera que se hizo en la biblioteca websockets ?

Todos 4 comentarios

Técnicamente, aiohttp crea una tarea por solicitud del cliente.
Al desconectarse el cliente, el sistema detiene la tarea lo antes posible.
La única forma de hacerlo es cancelando la tarea (supongamos que el controlador web está esperando la respuesta de la base de datos u otro servicio, también queremos cancelarlo sin esperar una operación explícita sobre la conexión al cliente websocket).

Task.cancel() se realiza enviando asyncio.CancelledError excepción, la clase de excepción se deriva del estándar Exception . Este es un comportamiento de asyncio, nada específico de aiohttp en sí.

Lo único que podría sugerir es capturar CancelledError en su controlador explícitamente:

try:
    ...
except asyncio.CancelledError:
    pass
except Exception as exc:
    log(exc)

O simplemente no podría captar un tipo tan amplio como Exception .

Veo dos opciones:

  • no cambie nada, CancelledError es normal en el mundo asincrónico.
  • captura CancelledError y devuelve closed mensaje. Creo que esta es una mejor solución para el controlador webocket.

¿Quizás es mejor introducir una excepción ConnectionClosed separada, de la misma manera que se hizo en la biblioteca websockets ?

Técnicamente, aiohttp crea una tarea por solicitud del cliente.
Al desconectarse el cliente, el sistema detiene la tarea lo antes posible.
La única forma de hacerlo es cancelando la tarea (supongamos que el controlador web está esperando la respuesta de la base de datos u otro servicio, también queremos cancelarlo sin esperar una operación explícita sobre la conexión al cliente websocket).

Creo que este comportamiento me acaba de morder. Tenía un código como este:

async def handler(request):
    ws = web.WebSocketResponse()
    await ws.prepare(request)

    async with contextlib.AsyncExitStack() as stack:
        # acquire_resource_X are async context managers
        await stack.enter_async_context(acquire_resource_1())
        await stack.enter_async_context(acquire_resource_2())
        await stack.enter_async_context(acquire_resource_3())

        async for msg in ws:
            # do stuff

    await ws.close()

    return ws

Después de ponerlo en producción, descubrí que la parte de salida de acquire_resource_3() se omitiría silenciosamente. Más registros revelaron que se estaba recaudando CancelledError dentro de acquire_resource_3 . Esto es lo que creo que sucedió:

  • El cliente cierra WebSocket
  • async for msg in ws bucle AsyncExitStack comienza a desenrollarse, la parte de salida de acquire_resource_3 comienza a ejecutarse, golpea un await
  • aiohttp cancela la tarea del controlador
  • CancelledError se recauda dentro de handler en el actual await , que está dentro de acquire_resource_3 , por lo tanto, se omite la parte restante de acquire_resource_3
  • La parte de salida de acquire_resource_2 y acquire_resource_1 todavía se ejecuta normalmente, ya que desde su perspectiva simplemente están saliendo de un contexto asíncrono en una excepción

Este es un problema realmente extraño, particularmente porque rompe la expectativa de que la parte saliente de un administrador de contexto siempre se ejecutará. Básicamente, tuve que proteger todos los contextos asíncronos de la cancelación, así:

async def handler(request):
    ws = web.WebSocketResponse()
    await ws.prepare(request)

    await asyncio.shield(asyncio.ensure_future(actually_do_stuff(ws)))

    return ws

async def actually_do_stuff(ws):
    async with contextlib.AsyncExitStack() as stack:
        # acquire_resource_X are async context managers
        await stack.enter_async_context(acquire_resource_1())
        await stack.enter_async_context(acquire_resource_2())
        await stack.enter_async_context(acquire_resource_3())

        async for msg in ws:
            # do stuff

    await ws.close()

¿Hay una mejor manera de hacer esto?

¿Fue útil esta página
0 / 5 - 0 calificaciones

Temas relacionados

deckar01 picture deckar01  ·  4Comentarios

yuval-lb picture yuval-lb  ·  5Comentarios

amsb picture amsb  ·  3Comentarios

asvetlov picture asvetlov  ·  4Comentarios

Smosker picture Smosker  ·  3Comentarios