|
1 | 1 | """Tests for TCP connection handling, including proper and timely close.""" |
2 | 2 |
|
3 | 3 | import errno |
| 4 | +from re import match as _matches_pattern |
4 | 5 | import socket |
5 | 6 | import time |
6 | 7 | import logging |
@@ -700,6 +701,263 @@ def _close_kernel_socket(self): |
700 | 701 | assert _close_kernel_socket.exception_leaked is exception_leaks |
701 | 702 |
|
702 | 703 |
|
| 704 | +def test_broken_connection_during_http_communication_fallback( # noqa: WPS118 |
| 705 | + monkeypatch, |
| 706 | + test_client, |
| 707 | + testing_server, |
| 708 | + wsgi_server_thread, |
| 709 | +): |
| 710 | + """Test that unhandled internal error cascades into shutdown.""" |
| 711 | + def _raise_connection_reset(*_args, **_kwargs): |
| 712 | + raise ConnectionResetError(666) |
| 713 | + |
| 714 | + def _read_request_line(self): |
| 715 | + monkeypatch.setattr(self.conn.rfile, 'close', _raise_connection_reset) |
| 716 | + monkeypatch.setattr(self.conn.wfile, 'write', _raise_connection_reset) |
| 717 | + _raise_connection_reset() |
| 718 | + |
| 719 | + monkeypatch.setattr( |
| 720 | + test_client.server_instance.ConnectionClass.RequestHandlerClass, |
| 721 | + 'read_request_line', |
| 722 | + _read_request_line, |
| 723 | + ) |
| 724 | + |
| 725 | + test_client.get_connection().send(b'GET / HTTP/1.1') |
| 726 | + wsgi_server_thread.join() # no extra logs upon server termination |
| 727 | + |
| 728 | + actual_log_entries = testing_server.error_log.calls[:] |
| 729 | + testing_server.error_log.calls.clear() # prevent post-test assertions |
| 730 | + |
| 731 | + expected_log_entries = ( |
| 732 | + (logging.WARNING, r'^socket\.error 666$'), |
| 733 | + ( |
| 734 | + logging.INFO, |
| 735 | + '^Got a connection error while handling a connection ' |
| 736 | + r'from .*:\d{1,5} \(666\)', |
| 737 | + ), |
| 738 | + ( |
| 739 | + logging.CRITICAL, |
| 740 | + r'A fatal exception happened\. Setting the server interrupt flag ' |
| 741 | + r'to ConnectionResetError\(666\) and giving up\.\n\nPlease, ' |
| 742 | + 'report this on the Cheroot tracker at ' |
| 743 | + r'<https://github\.com/cherrypy/cheroot/issues/new/choose>, ' |
| 744 | + 'providing a full reproducer with as much context and details ' |
| 745 | + r'as possible\.$', |
| 746 | + ), |
| 747 | + ) |
| 748 | + |
| 749 | + assert len(actual_log_entries) == len(expected_log_entries) |
| 750 | + |
| 751 | + for ( # noqa: WPS352 |
| 752 | + (expected_log_level, expected_msg_regex), |
| 753 | + (actual_msg, actual_log_level, _tb), |
| 754 | + ) in zip(expected_log_entries, actual_log_entries): |
| 755 | + assert expected_log_level == actual_log_level |
| 756 | + assert _matches_pattern(expected_msg_regex, actual_msg) is not None, ( |
| 757 | + f'{actual_msg !r} does not match {expected_msg_regex !r}' |
| 758 | + ) |
| 759 | + |
| 760 | + |
| 761 | +def test_kb_int_from_http_handler( |
| 762 | + test_client, |
| 763 | + testing_server, |
| 764 | + wsgi_server_thread, |
| 765 | +): |
| 766 | + """Test that a keyboard interrupt from HTTP handler causes shutdown.""" |
| 767 | + def _trigger_kb_intr(_req, _resp): |
| 768 | + raise KeyboardInterrupt('simulated test handler keyboard interrupt') |
| 769 | + testing_server.wsgi_app.handlers['/kb_intr'] = _trigger_kb_intr |
| 770 | + |
| 771 | + http_conn = test_client.get_connection() |
| 772 | + http_conn.putrequest('GET', '/kb_intr', skip_host=True) |
| 773 | + http_conn.putheader('Host', http_conn.host) |
| 774 | + http_conn.endheaders() |
| 775 | + wsgi_server_thread.join() # no extra logs upon server termination |
| 776 | + |
| 777 | + actual_log_entries = testing_server.error_log.calls[:] |
| 778 | + testing_server.error_log.calls.clear() # prevent post-test assertions |
| 779 | + |
| 780 | + expected_log_entries = ( |
| 781 | + ( |
| 782 | + logging.DEBUG, |
| 783 | + '^Got a server shutdown request while handling a connection ' |
| 784 | + r'from .*:\d{1,5} \(simulated test handler keyboard interrupt\)$', |
| 785 | + ), |
| 786 | + ( |
| 787 | + logging.DEBUG, |
| 788 | + '^Setting the server interrupt flag to KeyboardInterrupt' |
| 789 | + r"\('simulated test handler keyboard interrupt'\)$", |
| 790 | + ), |
| 791 | + ( |
| 792 | + logging.INFO, |
| 793 | + '^Keyboard Interrupt: shutting down$', |
| 794 | + ), |
| 795 | + ) |
| 796 | + |
| 797 | + assert len(actual_log_entries) == len(expected_log_entries) |
| 798 | + |
| 799 | + for ( # noqa: WPS352 |
| 800 | + (expected_log_level, expected_msg_regex), |
| 801 | + (actual_msg, actual_log_level, _tb), |
| 802 | + ) in zip(expected_log_entries, actual_log_entries): |
| 803 | + assert expected_log_level == actual_log_level |
| 804 | + assert _matches_pattern(expected_msg_regex, actual_msg) is not None, ( |
| 805 | + f'{actual_msg !r} does not match {expected_msg_regex !r}' |
| 806 | + ) |
| 807 | + |
| 808 | + |
| 809 | +def test_unhandled_exception_in_request_handler( |
| 810 | + mocker, |
| 811 | + monkeypatch, |
| 812 | + test_client, |
| 813 | + testing_server, |
| 814 | + wsgi_server_thread, |
| 815 | +): |
| 816 | + """Ensure worker threads are resilient to in-handler exceptions.""" |
| 817 | + |
| 818 | + class SillyMistake(BaseException): # noqa: WPS418, WPS431 |
| 819 | + """A simulated crash within an HTTP handler.""" |
| 820 | + |
| 821 | + def _trigger_scary_exc(_req, _resp): |
| 822 | + raise SillyMistake('simulated unhandled exception 💣 in test handler') |
| 823 | + |
| 824 | + testing_server.wsgi_app.handlers['/scary_exc'] = _trigger_scary_exc |
| 825 | + |
| 826 | + server_connection_close_spy = mocker.spy( |
| 827 | + test_client.server_instance.ConnectionClass, |
| 828 | + 'close', |
| 829 | + ) |
| 830 | + |
| 831 | + http_conn = test_client.get_connection() |
| 832 | + http_conn.putrequest('GET', '/scary_exc', skip_host=True) |
| 833 | + http_conn.putheader('Host', http_conn.host) |
| 834 | + http_conn.endheaders() |
| 835 | + |
| 836 | + # NOTE: This spy ensure the log entry gets recorded before we're testing |
| 837 | + # NOTE: them and before server shutdown, preserving their order and making |
| 838 | + # NOTE: the log entry presence non-flaky. |
| 839 | + while not server_connection_close_spy.called: # noqa: WPS328 |
| 840 | + pass |
| 841 | + |
| 842 | + assert len(testing_server.requests._threads) == 10 |
| 843 | + while testing_server.requests.idle < 10: # noqa: WPS328 |
| 844 | + pass |
| 845 | + assert len(testing_server.requests._threads) == 10 |
| 846 | + testing_server.interrupt = SystemExit('test requesting shutdown') |
| 847 | + assert not testing_server.requests._threads |
| 848 | + wsgi_server_thread.join() # no extra logs upon server termination |
| 849 | + |
| 850 | + actual_log_entries = testing_server.error_log.calls[:] |
| 851 | + testing_server.error_log.calls.clear() # prevent post-test assertions |
| 852 | + |
| 853 | + expected_log_entries = ( |
| 854 | + ( |
| 855 | + logging.ERROR, |
| 856 | + '^Unhandled error while processing an incoming connection ' |
| 857 | + 'SillyMistake' |
| 858 | + r"\('simulated unhandled exception 💣 in test handler'\)$", |
| 859 | + ), |
| 860 | + ( |
| 861 | + logging.INFO, |
| 862 | + '^SystemExit raised: shutting down$', |
| 863 | + ), |
| 864 | + ) |
| 865 | + |
| 866 | + assert len(actual_log_entries) == len(expected_log_entries) |
| 867 | + |
| 868 | + for ( # noqa: WPS352 |
| 869 | + (expected_log_level, expected_msg_regex), |
| 870 | + (actual_msg, actual_log_level, _tb), |
| 871 | + ) in zip(expected_log_entries, actual_log_entries): |
| 872 | + assert expected_log_level == actual_log_level |
| 873 | + assert _matches_pattern(expected_msg_regex, actual_msg) is not None, ( |
| 874 | + f'{actual_msg !r} does not match {expected_msg_regex !r}' |
| 875 | + ) |
| 876 | + |
| 877 | + |
| 878 | +def test_remains_alive_post_unhandled_exception( |
| 879 | + mocker, |
| 880 | + monkeypatch, |
| 881 | + test_client, |
| 882 | + testing_server, |
| 883 | + wsgi_server_thread, |
| 884 | +): |
| 885 | + """Ensure worker threads are resilient to unhandled exceptions.""" |
| 886 | + |
| 887 | + class ScaryCrash(BaseException): # noqa: WPS418, WPS431 |
| 888 | + """A simulated crash during HTTP parsing.""" |
| 889 | + |
| 890 | + _orig_read_request_line = ( |
| 891 | + test_client.server_instance. |
| 892 | + ConnectionClass.RequestHandlerClass. |
| 893 | + read_request_line |
| 894 | + ) |
| 895 | + |
| 896 | + def _read_request_line(self): |
| 897 | + _orig_read_request_line(self) |
| 898 | + raise ScaryCrash(666) |
| 899 | + |
| 900 | + monkeypatch.setattr( |
| 901 | + test_client.server_instance.ConnectionClass.RequestHandlerClass, |
| 902 | + 'read_request_line', |
| 903 | + _read_request_line, |
| 904 | + ) |
| 905 | + |
| 906 | + server_connection_close_spy = mocker.spy( |
| 907 | + test_client.server_instance.ConnectionClass, |
| 908 | + 'close', |
| 909 | + ) |
| 910 | + |
| 911 | + # NOTE: The initial worker thread count is 10. |
| 912 | + assert len(testing_server.requests._threads) == 10 |
| 913 | + |
| 914 | + test_client.get_connection().send(b'GET / HTTP/1.1') |
| 915 | + |
| 916 | + # NOTE: This spy ensure the log entry gets recorded before we're testing |
| 917 | + # NOTE: them and before server shutdown, preserving their order and making |
| 918 | + # NOTE: the log entry presence non-flaky. |
| 919 | + while not server_connection_close_spy.called: # noqa: WPS328 |
| 920 | + pass |
| 921 | + |
| 922 | + # NOTE: This checks for whether there's any crashed threads |
| 923 | + while testing_server.requests.idle < 10: # noqa: WPS328 |
| 924 | + pass |
| 925 | + assert len(testing_server.requests._threads) == 10 |
| 926 | + assert all( |
| 927 | + worker_thread.is_alive() |
| 928 | + for worker_thread in testing_server.requests._threads |
| 929 | + ) |
| 930 | + testing_server.interrupt = SystemExit('test requesting shutdown') |
| 931 | + assert not testing_server.requests._threads |
| 932 | + wsgi_server_thread.join() # no extra logs upon server termination |
| 933 | + |
| 934 | + actual_log_entries = testing_server.error_log.calls[:] |
| 935 | + testing_server.error_log.calls.clear() # prevent post-test assertions |
| 936 | + |
| 937 | + expected_log_entries = ( |
| 938 | + ( |
| 939 | + logging.ERROR, |
| 940 | + '^Unhandled error while processing an incoming connection ' |
| 941 | + r'ScaryCrash\(666\)$', |
| 942 | + ), |
| 943 | + ( |
| 944 | + logging.INFO, |
| 945 | + '^SystemExit raised: shutting down$', |
| 946 | + ), |
| 947 | + ) |
| 948 | + |
| 949 | + assert len(actual_log_entries) == len(expected_log_entries) |
| 950 | + |
| 951 | + for ( # noqa: WPS352 |
| 952 | + (expected_log_level, expected_msg_regex), |
| 953 | + (actual_msg, actual_log_level, _tb), |
| 954 | + ) in zip(expected_log_entries, actual_log_entries): |
| 955 | + assert expected_log_level == actual_log_level |
| 956 | + assert _matches_pattern(expected_msg_regex, actual_msg) is not None, ( |
| 957 | + f'{actual_msg !r} does not match {expected_msg_regex !r}' |
| 958 | + ) |
| 959 | + |
| 960 | + |
703 | 961 | @pytest.mark.parametrize( |
704 | 962 | 'timeout_before_headers', |
705 | 963 | ( |
|
0 commit comments