Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow tests requiring multiple servers at once #52

Closed
wants to merge 3 commits into from
Closed

Allow tests requiring multiple servers at once #52

wants to merge 3 commits into from

Conversation

tenuki
Copy link

@tenuki tenuki commented Nov 29, 2020

Remove Plugin.SERVER singleton in order to make http-server reusable

Based on the need we had we used pytest-httpserver to test multiple server instances running at once. After checking documentation and background we found that on the purpose of the single was to override start-up time on werzeuk server library. We confirmed that that is not the case anymore at least at Werkzeug==1.0.1 (so we need to update documentation too).

Based on the need we had we used pytest-httpserver to test multiple server instances running at once. After checking documentation and background we found that on the purpose of the single was to override start-up time on werzeuk server library. We confirmed that that is not the case anymore at least at Werkzeug==1.0.1.
@codecov
Copy link

codecov bot commented Nov 29, 2020

Codecov Report

Merging #52 (580a214) into master (d77a677) will decrease coverage by 2.58%.
The diff coverage is 100.00%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master      #52      +/-   ##
==========================================
- Coverage   95.58%   92.99%   -2.59%     
==========================================
  Files           3        3              
  Lines         430      414      -16     
==========================================
- Hits          411      385      -26     
- Misses         19       29      +10     
Impacted Files Coverage Δ
pytest_httpserver/pytest_plugin.py 100.00% <100.00%> (ø)
pytest_httpserver/httpserver.py 92.48% <0.00%> (-2.60%) ⬇️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update d77a677...580a214. Read the comment docs.

@csernazs
Copy link
Owner

Thanks for the PR.

Regarding

we found that on the purpose of the single was to override start-up time on werzeuk server library.

The background documentations (https://pytest-httpserver.readthedocs.io/en/latest/background.html#server-starting-and-stopping) says that the stopping is taking much time.

For some reason, werkzeug (the http server used) needs about 1 second to stop itself. Adding this time to each test is not acceptable in most of the cases.

According to my experience it it still an issue with werkzeug 1.0.1. Running make quick-test takes significantly more time when run with your commits compared to origin/master.

On my machine it is 10.59s vs 2.92s and the time difference increases with the amount of server restarts (which is proportional to the number of test cases).

I completely understand that you want multiple servers, but I think this is supported the released versions of pytest-httpserver.

If you want to run a new server instance, you can create your own fixture which creates a new httpserver instance, and thereby you can avoid using the provided fixture. According to readme:

with HTTPServer() as httpserver:
    # set up the server to serve /foobar with the json
    httpserver.expect_request("/foobar").respond_with_json({"foo": "bar"})
    # check that the request is served
    print(requests.get(httpserver.url_for("/foobar")).json())

This is very similar to the code you have done in test_multi.py: it does not use the httpserver fixture provided by the library but it uses its own server. According to my best knowledge, this should work with the current code on origin/master and not requires changing the plugin's code - so the users which needs only one server will be still happy as the performance remains the same and on the other hand you can run as many instances of the server as you want.

@tenuki
Copy link
Author

tenuki commented Nov 29, 2020

For some reason, werkzeug (the http server used) needs about 1 second to stop itself. Adding this time to each test is not acceptable in most of the cases.
According to my experience it it still an issue with werkzeug 1.0.1. Running make quick-test takes significantly more time when run with your commits compared to origin/master.

On my machine it is 10.59s vs 2.92s and the time difference increases with the amount of server restarts (which is proportional to the number of test cases).

I wouldn't had suggested it if I would know the problem persists, I'm sorry, according to my tests, the code with and without the patch took the same time. I known it doesn't prove anything, but check ticket #50. Is it possible that on windows there is no such problem?

If you want to run a new server instance, you can create your own fixture which creates a new httpserver instance, and thereby you can avoid using the provided fixture. According to readme:

[..]

This is very similar to the code you have done in test_multi.py: it does not use the httpserver fixture provided by the library but it uses its own server. According to my best knowledge, this should work with the current code on origin/master and not requires changing the plugin's code - so the users which needs only one server will be still happy as the performance remains the same and on the other hand you can run as many instances of the server as you want.

Sure, I have already made something like that, thanks anyway! (Only thing was that I didn't found it in the readme or I missed it there).

Thanks for your time!

@tenuki tenuki closed this Nov 29, 2020
@csernazs
Copy link
Owner

Aww, first: I'm terribly sorry, I completely missed the issue you have created.. My mistake.

I haven't yet realized that there's anyone using it on windows, so it was trivial for me that if this occurs on Linux it will occure for everybody, but this is not true. I think I should add tests for windows in github actions from now to not break the library on your system.

I've did a little bit of research of what is happening and this is the code which triggers the issue:

import time
from threading import Thread
from werkzeug.serving import make_server

# this never gets called
def application():
    pass


def main():
    server = make_server("127.0.0.1", 9999, application)
    thread = Thread(target=lambda: server.serve_forever())
    thread.start()

    t1 = time.time()
    server.shutdown()
    print(time.time()-t1)

    thread.join()

if __name__ == "__main__":
    main()

For me this prints out a value near to 0.5 seconds.
The server.shutdown() is actually calling the python stdlib's socketserver's shutdown() method, so I get to this point:
https://github.com/python/cpython/blob/fc40b3020cf3c869833fd5d3720cf9768fe3bb46/Lib/socketserver.py#L232

Here it creates a loop, which waits for some activity on a file descriptor for maximum poll_interval time, which is 0.5 seconds. When it times our or there's some activity, it checks the shutdown flag and if it is True it breaks out from the loop.
The reason of this delay is that it waits for up to 0.5 seconds (in the case there's some IO happening it could be lower).
It could be lowered to some smaller value but even 0.1 seconds would add 1 second of wasted time for 10 test running, and a little bit more CPU usage caused by the lower value.

Probably the selector works differently on windows as it a completely different flavour (I don't know how windows works with this, so this is just a guess).

Anyway, I think I'll update the background.rst with this finding.

@tenuki
Copy link
Author

tenuki commented Nov 30, 2020

Hello!

Don't worry about the issue, and thanks again for your time.

Two different things:

First, I think the problem with/in my windows isn't actually at close but at open/connection. Changing this will fix it however I'm not sure this is something someone would like to do (I would probably be a misconfiguration in my box):

-    DEFAULT_LISTEN_HOST = "localhost"
+    DEFAULT_LISTEN_HOST = "127.0.0.1"

The close/shutdown delay issue: it is bad to find out it is base system (python) problem :-( . One possible, perhaps not too happy workaround is to close the socket on before hand:

    def stop(self):
        """
        Stop the running server.

        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.

        Only a running server can be stopped. If the sever is not running, :py:class`HTTPServerError`
        will be raised.
        """
        if not self.is_running():
            raise HTTPServerError("Server is not running")

+        self.server.server_close()
        self.server.shutdown()
        self.server_thread.join()
        self.server = None
        self.server_thread = None

On the not expected side effect side, it would happen that about half of the time, the thread which is at .. .server_forever() will get out with exception.. which I don't think it should be a problem, as it would always be possible to catch that there, and in particular if that was an ad-hoc thread created only for that purpose better.. but I'm not sure yet about how pytest-httpserver actually creates the servers..

Anyway.. with those two changes, the tests in windows will run in 3seconds :
================================================= 1 failed, 46 passed, 2 skipped in 2.99s ==================================================

Oh.. the other broken thing is the ssl test.. because the default host in this case was ìp`-address based instead of localhost.. I think it would be acceptable to have that one in particular back to localhost because delay in only one tests is acceptable and better than losing one test.

But, again, I'm not sure about the change from name to ip..

@csernazs
Copy link
Owner

Regarding the hostname issue: to by best knowledge the host and port are passed to the bind() call of the socket API, which takes the IP address, and I guess python is very kind and does the name resolution behind the scenes before making the call to bind(). I have no info about on windows, AFAIK windows provides also the socket API same as Linux but with some limited address families.
sock_bind starts at: https://github.com/python/cpython/blob/1244c816d7cdfa8a26d1671cab67122a8c5271a7/Modules/socketmodule.c#L3101
This then ends up in setipaddr, which does the name resolution (system trace agrees that there's a name resolution happening before bind()).
https://github.com/python/cpython/blob/1244c816d7cdfa8a26d1671cab67122a8c5271a7/Modules/socketmodule.c#L1037

Maybe it takes long on windows, but it sounds very strange. I need to make attempts to re-produce this on my windows, but again I'm far from a windows expert so don't expect me to dig into the source code there.

Regarding server_close, I find it a bit risky thing to do so, the documentation explicitly specifies that shutdown() needs to be called:
https://docs.python.org/3/library/socketserver.html#socketserver.BaseServer.shutdown

Tell the serve_forever() loop to stop and wait until it does. shutdown() must be called while serve_forever() is running in a different thread otherwise it will deadlock.

While server_close is something you want to override, I think:

Clean up the server. May be overridden.

What I was experimenting is that running a different wsgi server behind werkzeug, to be honest socketserver is not the cutting edge technology, this is specified in the documentation as well, but pytest is not production and it is good to reduce the dependencies (and it looked ok at the beginning to not create anything complicated).

But again, running multiple servers works can be done, you are not obliged to use the provided httpserver fixture.
You can also override the default bind address in such case easily (constructor parameter, environment variable, class attribute changing, etc) if that makes your life easier.

@tenuki
Copy link
Author

tenuki commented Dec 1, 2020

Maybe it takes long on windows, but it sounds very strange. I need to make attempts to re-produce this on my windows, but again I'm far from a windows expert so don't expect me to dig into the source code there.

I've found where the "problem"(?) is on windows.. or at least, what is happening. :-)
The delay part isn't at bind(..) but when client tries to connect(..) to it and it have to resolve localhost.

This line is where client iterates over all interfaces trying to connect
https://github.com/urllib3/urllib3/blob/master/src/urllib3/util/connection.py#L59

Actually I have more interfaces in linux than in windows:

  • linux: [
    • (<AddressFamily.AF_INET: 2>, <SocketKind.SOCK_STREAM: 1>, 6, '', ('127.0.0.1', 1234)),
    • (<AddressFamily.AF_INET: 2>, <SocketKind.SOCK_DGRAM: 2>, 17, '', ('127.0.0.1', 1234)),
    • (<AddressFamily.AF_INET: 2>, <SocketKind.SOCK_RAW: 3>, 0, '', ('127.0.0.1', 1234))]
  • windows: [
    • (<AddressFamily.AF_INET6: 23>, 0, 0, '', ('::1', 13251, 0, 0)),
    • (<AddressFamily.AF_INET: 2>, 0, 0, '', ('127.0.0.1', 13251))]

The problem is the order.. or the timeout as it takes 5seconds to discard ipv6 and try to connect to '127.0.0.1' in ipv4.
In linux, that interfaces in the first attempt.

Regarding server_close, I find it a bit risky thing to do so, the documentation explicitly specifies that shutdown() needs to be called:
https://docs.python.org/3/library/socketserver.html#socketserver.BaseServer.shutdown

Tell the serve_forever() loop to stop and wait until it does. shutdown() must be called while serve_forever() is running in a different thread otherwise it will deadlock.

I think I wasn't clear. I didn't mean to replace server.shutdown() !! My suggestion was to call server.socket_close() just before the server.shutdown(). By doing this, you make it exit select call and and then prevent new loop by calling shutdown()..

There is a little race condition where the shutdown isn't call fast enough. In that case, select() is called again and the socket is invalid which will produce an exception which will raise up to serve_forever.. it could be ignored here:

    def thread_target(self):
        """
        This method serves as a thread target when the server is started.
        This should not be called directly, but can be overridden to tailor it to your needs.
        """
        try:
            self.server.serve_forever()
        except (check-which-exception):
            pass

I think that the race-condition could be prevented if "closing/shutdown" signaling state variables in SocketServer were not private variables (or by work-around the name mangling).. in order to mark the shutdown state before the socket close and the shutdown. That would prevent the race condition.

What I was experimenting is that running a different wsgi server behind werkzeug, to be honest socketserver is not the cutting edge technology, this is specified in the documentation as well, but pytest is not production and it is good to reduce the dependencies (and it looked ok at the beginning to not create anything complicated).

I think it makes sense!

But again, running multiple servers works can be done, you are not obliged to use the provided httpserver fixture.
You can also override the default bind address in such case easily (constructor parameter, environment variable, class attribute changing, etc) if that makes your life easier.

Yes yes, we have that already addressed, thank you!! I just would like that it be easier to get different servers .. and if in retribution for your lib I can help you find out something interesting, it would be win-win!

Thanks!

@csernazs
Copy link
Owner

csernazs commented Dec 1, 2020

That's weird, it seems like a client error for first first look. If it tries to connect to ipv6 address, why the fall-back to ipv4 takes too long? And how it relates to the address you specify for bind? Oh, maybe "localhost" is resolved to both ipv6 and ipv4, and in such case werkzeug listens on both address family? On my Linux, if I specify "localhost" it only listens on ipv4. I'm almost certain that if you specify 127.0.0.1 then it will pick up ipv4-only.
But then you must have issues with other daemons listening on localhost, right? I didn't have time to look at the problem on my windows yet - if it is a general problem then it should be addressed. My guess is that the TLS issue is then caused by using using url_for() which reports the hostname you set for the server, maybe if you replace the host part of the URL to "localhost" then it will be okay.

I understood that you want to call both server_close and shutdown, but from the documentation it seemed for me that server_close should not be called from outside. But looking at the code in socketserver.py, this may do the trick, as it closes the server socket, which terminates the selector in the thread so that 0.5 second waiting time is eliminated.
I'll check it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants