csernazs / pytest-httpserver

Http server for pytest to test http clients
MIT License
214 stars 28 forks source link

Program stuck on server.stop() #263

Closed delthas closed 11 months ago

delthas commented 1 year ago

Hi,

My script is stuck on server.stop() (happens very rarely). I managed to get a stack trace of all threads using pyrasite:

[...]
    self.server.stop()
  File "/usr/local/lib/python3.8/dist-packages/pytest_httpserver/httpserver.py", line 892, in stop
    self.server.shutdown()
  File "/usr/lib/python3.8/socketserver.py", line 252, in shutdown
    self.__is_shut_down.wait()
  File "/usr/lib/python3.8/threading.py", line 558, in wait
    signaled = self._cond.wait(timeout)
  File "/usr/lib/python3.8/threading.py", line 302, in wait
    waiter.acquire()
  File "/usr/local/lib/python3.8/dist-packages/pytest_httpserver/httpserver.py", line 848, in thread_target
    self.server.serve_forever()
  File "/usr/local/lib/python3.8/dist-packages/werkzeug/serving.py", line 804, in serve_forever
    super().serve_forever(poll_interval=poll_interval)
  File "/usr/lib/python3.8/socketserver.py", line 237, in serve_forever
    self._handle_request_noblock()
  File "/usr/lib/python3.8/socketserver.py", line 316, in _handle_request_noblock
    self.process_request(request, client_address)
  File "/usr/lib/python3.8/socketserver.py", line 347, in process_request
    self.finish_request(request, client_address)
  File "/usr/lib/python3.8/socketserver.py", line 360, in finish_request
    self.RequestHandlerClass(request, client_address, self)
  File "/usr/lib/python3.8/socketserver.py", line 747, in __init__
    self.handle()
  File "/usr/local/lib/python3.8/dist-packages/werkzeug/serving.py", line 392, in handle
    super().handle()
  File "/usr/lib/python3.8/http/server.py", line 427, in handle
    self.handle_one_request()
  File "/usr/lib/python3.8/http/server.py", line 395, in handle_one_request
    self.raw_requestline = self.rfile.readline(65537)
  File "/usr/lib/python3.8/socket.py", line 669, in readinto
    return self._sock.recv_into(b)

So it would seem that my call on stop is stuck on waiting for the server recv to finish, maybe due to a broken connection from a client.

The doc for stop says "Notifies the server thread about the intention of the stopping, and the thread will terminate itself. This needs about 0.5 seconds in worst case." Is this expected behavior? Is there a way to set a max timeout?

csernazs commented 1 year ago

Hi there,

Sorry for the delay.

Server stopping works in the following way (sorry if you already know this, I'm writing these just to be on the same page). You indicate you want to stop the server. Then a flag is set in the socketserver object that you want to stop it. It will check for this flat at certain points, there's a polling with a timeout for 0.5 secs set (this is why pytest-httpserver's documentation says you have to wait 0.5 secs at worst case), so if there's no IO, the select will time out and it will check the flag, and then exit.

If this flag is not set, and there's some I/O on the socket it calls the handler which is the worker function. As long as it is running, there will be no new requests accepted and it won't check this shutdown flag, as the server is single threaded.

I think in your case one of the connections made by the client were not closed, so this resulted this situation that the server could not stop as it was still running the handler. If there's a reproduction I can look at it closer.

To fix this case you can run threaded server, so it will run a separate thread for each connection, and these will be running in parallel. This alone won't fix the issue, but you can set whether the server should wait for all the threads.

See block_on_close in the documentation: https://docs.python.org/3/library/socketserver.html#socketserver.ThreadingMixIn

If you set this to False it will leave behind the threads, which not a good practice in my view, but in your case this would be the only solution I think.

How to implement this:

If you want to override how the server object (I mean, socketserver's server object) is created, you need to override the start method of the HTTPServer class. Temporarily you can copy the method to the new class and update it with threaded=True.

class ThreadedHTTPServer(HTTPServer):
    def start(self):
        """
        Start the server in a thread.

        This method returns immediately (e.g. does not block), and it's the caller's
        responsibility to stop the server (by calling :py:meth:`stop`) when it is no longer needed).

        If the sever is not stopped by the caller and execution reaches the end, the
        program needs to be terminated by Ctrl+C or by signal as it will not terminate until
        the thread is stopped.

        If the sever is already running :py:class:`HTTPServerError` will be raised. If you are
        unsure, call :py:meth:`is_running` first.

        There's a context interface of this class which stops the server when the context block ends.
        """
        if self.is_running():
            raise HTTPServerError("Server is already running")

        self.server = make_server(self.host, self.port, self.application, ssl_context=self.ssl_context, threaded=True)
        self.port = self.server.port  # Update port (needed if `port` was set to 0)
        self.server_thread = threading.Thread(target=self.thread_target)
        self.server_thread.start()

(spot the threaded=True parameter)

You need to override the make_httpserver fixture (session scoped) to make the httpserver fixture updated:


@pytest.fixture(scope="session")
def make_httpserver(httpserver_listen_address, httpserver_ssl_context):
    host, port = httpserver_listen_address
    if not host:
        host = HTTPServer.DEFAULT_LISTEN_HOST
    if not port:
        port = HTTPServer.DEFAULT_LISTEN_PORT

    server = ThreadedHTTPServer(host=host, port=port, ssl_context=httpserver_ssl_context)
    server.start()
    yield server
    server.clear()
    if server.is_running():
        server.stop()

(spot the ThreadedHTTPServer class)

You also need to set this block_on_close boolean parameter in the class attribute to False (before starting the server):

import socketserver
socketserver.ThreadingMixIn.block_on_close = False

By this way, you can avoid waiting for the client. But I'd suggest debugging the client and try specifying a timeout in it.

If you have a minimal example for the reproduction I can look at it in detail (and hopefully that won't take that much time to respond :)).

csernazs commented 11 months ago

hi @delthas , have you seen my last comment here?

delthas commented 11 months ago

Hi @csernazs , yes, thanks for your answer. I didn't have time to reproduce/fix it yet.