xref: /unit/test/test_python_application.py (revision 2616:ab2896c980ab)
1import grp
2import os
3import pwd
4import re
5import subprocess
6import time
7import venv
8
9import pytest
10from packaging import version
11
12from unit.applications.lang.python import ApplicationPython
13
14prerequisites = {'modules': {'python': 'all'}}
15
16client = ApplicationPython()
17
18
19def test_python_application_variables(date_to_sec_epoch, sec_epoch):
20    client.load('variables')
21
22    body = 'Test body string.'
23
24    resp = client.http(
25        f"""POST / HTTP/1.1
26Host: localhost
27Content-Length: {len(body)}
28Custom-Header: blah
29Custom-hEader: Blah
30Content-Type: text/html
31Connection: close
32custom-header: BLAH
33
34{body}""".encode(),
35        raw=True,
36    )
37
38    assert resp['status'] == 200, 'status'
39    headers = resp['headers']
40    header_server = headers.pop('Server')
41    assert re.search(r'Unit/[\d\.]+', header_server), 'server header'
42    assert (
43        headers.pop('Server-Software') == header_server
44    ), 'server software header'
45
46    date = headers.pop('Date')
47    assert date[-4:] == ' GMT', 'date header timezone'
48    assert abs(date_to_sec_epoch(date) - sec_epoch) < 5, 'date header'
49
50    assert headers == {
51        'Connection': 'close',
52        'Content-Length': str(len(body)),
53        'Content-Type': 'text/html',
54        'Request-Method': 'POST',
55        'Request-Uri': '/',
56        'Http-Host': 'localhost',
57        'Server-Protocol': 'HTTP/1.1',
58        'Custom-Header': 'blah, Blah, BLAH',
59        'Wsgi-Version': '(1, 0)',
60        'Wsgi-Url-Scheme': 'http',
61        'Wsgi-Multithread': 'False',
62        'Wsgi-Multiprocess': 'True',
63        'Wsgi-Run-Once': 'False',
64    }, 'headers'
65    assert resp['body'] == body, 'body'
66
67
68def test_python_application_query_string():
69    client.load('query_string')
70
71    resp = client.get(url='/?var1=val1&var2=val2')
72
73    assert (
74        resp['headers']['Query-String'] == 'var1=val1&var2=val2'
75    ), 'Query-String header'
76
77
78def test_python_application_query_string_space():
79    client.load('query_string')
80
81    resp = client.get(url='/ ?var1=val1&var2=val2')
82    assert (
83        resp['headers']['Query-String'] == 'var1=val1&var2=val2'
84    ), 'Query-String space'
85
86    resp = client.get(url='/ %20?var1=val1&var2=val2')
87    assert (
88        resp['headers']['Query-String'] == 'var1=val1&var2=val2'
89    ), 'Query-String space 2'
90
91    resp = client.get(url='/ %20 ?var1=val1&var2=val2')
92    assert (
93        resp['headers']['Query-String'] == 'var1=val1&var2=val2'
94    ), 'Query-String space 3'
95
96    resp = client.get(url='/blah %20 blah? var1= val1 & var2=val2')
97    assert (
98        resp['headers']['Query-String'] == ' var1= val1 & var2=val2'
99    ), 'Query-String space 4'
100
101
102def test_python_application_prefix():
103    client.load('prefix', prefix='/api/rest')
104
105    def set_prefix(prefix):
106        client.conf(f'"{prefix}"', 'applications/prefix/prefix')
107
108    def check_prefix(url, script_name, path_info):
109        resp = client.get(url=url)
110        assert resp['status'] == 200
111        assert resp['headers']['Script-Name'] == script_name
112        assert resp['headers']['Path-Info'] == path_info
113
114    check_prefix('/ap', 'NULL', '/ap')
115    check_prefix('/api', 'NULL', '/api')
116    check_prefix('/api/', 'NULL', '/api/')
117    check_prefix('/api/res', 'NULL', '/api/res')
118    check_prefix('/api/restful', 'NULL', '/api/restful')
119    check_prefix('/api/rest', '/api/rest', '')
120    check_prefix('/api/rest/', '/api/rest', '/')
121    check_prefix('/api/rest/get', '/api/rest', '/get')
122    check_prefix('/api/rest/get/blah', '/api/rest', '/get/blah')
123
124    set_prefix('/api/rest/')
125    check_prefix('/api/rest', '/api/rest', '')
126    check_prefix('/api/restful', 'NULL', '/api/restful')
127    check_prefix('/api/rest/', '/api/rest', '/')
128    check_prefix('/api/rest/blah', '/api/rest', '/blah')
129
130    set_prefix('/app')
131    check_prefix('/ap', 'NULL', '/ap')
132    check_prefix('/app', '/app', '')
133    check_prefix('/app/', '/app', '/')
134    check_prefix('/application/', 'NULL', '/application/')
135
136    set_prefix('/')
137    check_prefix('/', 'NULL', '/')
138    check_prefix('/app', 'NULL', '/app')
139
140
141def test_python_application_query_string_empty():
142    client.load('query_string')
143
144    resp = client.get(url='/?')
145
146    assert resp['status'] == 200, 'query string empty status'
147    assert resp['headers']['Query-String'] == '', 'query string empty'
148
149
150def test_python_application_query_string_absent():
151    client.load('query_string')
152
153    resp = client.get()
154
155    assert resp['status'] == 200, 'query string absent status'
156    assert resp['headers']['Query-String'] == '', 'query string absent'
157
158
159@pytest.mark.skip('not yet')
160def test_python_application_server_port():
161    client.load('server_port')
162
163    assert (
164        client.get()['headers']['Server-Port'] == '8080'
165    ), 'Server-Port header'
166
167
168@pytest.mark.skip('not yet')
169def test_python_application_working_directory_invalid():
170    client.load('empty')
171
172    assert 'success' in client.conf(
173        '"/blah"', 'applications/empty/working_directory'
174    ), 'configure invalid working_directory'
175
176    assert client.get()['status'] == 500, 'status'
177
178
179def test_python_application_204_transfer_encoding():
180    client.load('204_no_content')
181
182    assert (
183        'Transfer-Encoding' not in client.get()['headers']
184    ), '204 header transfer encoding'
185
186
187def test_python_application_ctx_iter_atexit(wait_for_record):
188    client.load('ctx_iter_atexit')
189
190    resp = client.post(body='0123456789')
191
192    assert resp['status'] == 200, 'ctx iter status'
193    assert resp['body'] == '0123456789', 'ctx iter body'
194
195    assert 'success' in client.conf({"listeners": {}, "applications": {}})
196
197    assert wait_for_record(r'RuntimeError') is not None, 'ctx iter atexit'
198
199
200def test_python_keepalive_body():
201    client.load('mirror')
202
203    assert client.get()['status'] == 200, 'init'
204
205    body = '0123456789' * 500
206    (resp, sock) = client.post(
207        headers={
208            'Host': 'localhost',
209            'Connection': 'keep-alive',
210        },
211        start=True,
212        body=body,
213        read_timeout=1,
214    )
215
216    assert resp['body'] == body, 'keep-alive 1'
217
218    body = '0123456789'
219    resp = client.post(sock=sock, body=body)
220
221    assert resp['body'] == body, 'keep-alive 2'
222
223
224def test_python_keepalive_reconfigure():
225    client.load('mirror')
226
227    assert client.get()['status'] == 200, 'init'
228
229    body = '0123456789'
230    conns = 3
231    socks = []
232
233    for i in range(conns):
234        (resp, sock) = client.post(
235            headers={
236                'Host': 'localhost',
237                'Connection': 'keep-alive',
238            },
239            start=True,
240            body=body,
241            read_timeout=1,
242        )
243
244        assert resp['body'] == body, 'keep-alive open'
245
246        client.load('mirror', processes=i + 1)
247
248        socks.append(sock)
249
250    for i in range(conns):
251        (resp, sock) = client.post(
252            headers={
253                'Host': 'localhost',
254                'Connection': 'keep-alive',
255            },
256            start=True,
257            sock=socks[i],
258            body=body,
259            read_timeout=1,
260        )
261
262        assert resp['body'] == body, 'keep-alive request'
263
264        client.load('mirror', processes=i + 1)
265
266    for i in range(conns):
267        resp = client.post(sock=socks[i], body=body)
268
269        assert resp['body'] == body, 'keep-alive close'
270
271        client.load('mirror', processes=i + 1)
272
273
274def test_python_keepalive_reconfigure_2():
275    client.load('mirror')
276
277    assert client.get()['status'] == 200, 'init'
278
279    body = '0123456789'
280
281    (resp, sock) = client.post(
282        headers={
283            'Host': 'localhost',
284            'Connection': 'keep-alive',
285        },
286        start=True,
287        body=body,
288        read_timeout=1,
289    )
290
291    assert resp['body'] == body, 'reconfigure 2 keep-alive 1'
292
293    client.load('empty')
294
295    assert client.get()['status'] == 200, 'init'
296
297    (resp, sock) = client.post(start=True, sock=sock, body=body)
298
299    assert resp['status'] == 200, 'reconfigure 2 keep-alive 2'
300    assert resp['body'] == '', 'reconfigure 2 keep-alive 2 body'
301
302    assert 'success' in client.conf(
303        {"listeners": {}, "applications": {}}
304    ), 'reconfigure 2 clear configuration'
305
306    resp = client.get(sock=sock)
307
308    assert resp == {}, 'reconfigure 2 keep-alive 3'
309
310
311def test_python_atexit(wait_for_record):
312    client.load('atexit')
313
314    client.get()
315
316    assert 'success' in client.conf({"listeners": {}, "applications": {}})
317
318    assert wait_for_record(r'At exit called\.') is not None, 'atexit'
319
320
321def test_python_process_switch():
322    client.load('delayed', processes=2)
323
324    client.get(
325        headers={
326            'Host': 'localhost',
327            'Content-Length': '0',
328            'X-Delay': '5',
329            'Connection': 'close',
330        },
331        no_recv=True,
332    )
333
334    headers_delay_1 = {
335        'Connection': 'close',
336        'Host': 'localhost',
337        'Content-Length': '0',
338        'X-Delay': '1',
339    }
340
341    client.get(headers=headers_delay_1, no_recv=True)
342
343    time.sleep(0.5)
344
345    for _ in range(10):
346        client.get(headers=headers_delay_1, no_recv=True)
347
348    client.get(headers=headers_delay_1)
349
350
351@pytest.mark.skip('not yet')
352def test_python_application_start_response_exit():
353    client.load('start_response_exit')
354
355    assert client.get()['status'] == 500, 'start response exit'
356
357
358def test_python_application_input_iter():
359    client.load('input_iter')
360
361    body = '''0123456789
362next line
363
364last line'''
365
366    resp = client.post(body=body)
367    assert resp['body'] == body, 'input iter'
368    assert resp['headers']['X-Lines-Count'] == '4', 'input iter lines'
369
370
371def test_python_application_input_readline():
372    client.load('input_readline')
373
374    body = '''0123456789
375next line
376
377last line'''
378
379    resp = client.post(body=body)
380    assert resp['body'] == body, 'input readline'
381    assert resp['headers']['X-Lines-Count'] == '4', 'input readline lines'
382
383
384def test_python_application_input_readline_size():
385    client.load('input_readline_size')
386
387    body = '''0123456789
388next line
389
390last line'''
391
392    assert client.post(body=body)['body'] == body, 'input readline size'
393    assert (
394        client.post(body='0123')['body'] == '0123'
395    ), 'input readline size less'
396
397
398def test_python_application_input_readlines():
399    client.load('input_readlines')
400
401    body = '''0123456789
402next line
403
404last line'''
405
406    resp = client.post(body=body)
407    assert resp['body'] == body, 'input readlines'
408    assert resp['headers']['X-Lines-Count'] == '4', 'input readlines lines'
409
410
411def test_python_application_input_readlines_huge():
412    client.load('input_readlines')
413
414    body = (
415        '''0123456789 abcdefghi
416next line: 0123456789 abcdefghi
417
418last line: 987654321
419'''
420        * 512
421    )
422
423    assert (
424        client.post(body=body, read_buffer_size=16384)['body'] == body
425    ), 'input readlines huge'
426
427
428def test_python_application_input_read_length():
429    client.load('input_read_length')
430
431    body = '0123456789'
432
433    resp = client.post(
434        headers={
435            'Host': 'localhost',
436            'Input-Length': '5',
437            'Connection': 'close',
438        },
439        body=body,
440    )
441
442    assert resp['body'] == body[:5], 'input read length lt body'
443
444    resp = client.post(
445        headers={
446            'Host': 'localhost',
447            'Input-Length': '15',
448            'Connection': 'close',
449        },
450        body=body,
451    )
452
453    assert resp['body'] == body, 'input read length gt body'
454
455    resp = client.post(
456        headers={
457            'Host': 'localhost',
458            'Input-Length': '0',
459            'Connection': 'close',
460        },
461        body=body,
462    )
463
464    assert resp['body'] == '', 'input read length zero'
465
466    resp = client.post(
467        headers={
468            'Host': 'localhost',
469            'Input-Length': '-1',
470            'Connection': 'close',
471        },
472        body=body,
473    )
474
475    assert resp['body'] == body, 'input read length negative'
476
477
478@pytest.mark.skip('not yet')
479def test_python_application_errors_write(wait_for_record):
480    client.load('errors_write')
481
482    client.get()
483
484    assert (
485        wait_for_record(r'\[error\].+Error in application\.') is not None
486    ), 'errors write'
487
488
489def test_python_application_body_array():
490    client.load('body_array')
491
492    assert client.get()['body'] == '0123456789', 'body array'
493
494
495def test_python_application_body_io():
496    client.load('body_io')
497
498    assert client.get()['body'] == '0123456789', 'body io'
499
500
501def test_python_application_body_io_file():
502    client.load('body_io_file')
503
504    assert client.get()['body'] == 'body\n', 'body io file'
505
506
507@pytest.mark.skip('not yet')
508def test_python_application_syntax_error(skip_alert):
509    skip_alert(r'Python failed to import module "wsgi"')
510    client.load('syntax_error')
511
512    assert client.get()['status'] == 500, 'syntax error'
513
514
515def test_python_application_loading_error(skip_alert):
516    skip_alert(r'Python failed to import module "blah"')
517
518    client.load('empty', module="blah")
519
520    assert client.get()['status'] == 503, 'loading error'
521
522
523def test_python_application_close(wait_for_record):
524    client.load('close')
525
526    client.get()
527
528    assert wait_for_record(r'Close called\.') is not None, 'close'
529
530
531def test_python_application_close_error(wait_for_record):
532    client.load('close_error')
533
534    client.get()
535
536    assert wait_for_record(r'Close called\.') is not None, 'close error'
537
538
539def test_python_application_not_iterable(wait_for_record):
540    client.load('not_iterable')
541
542    client.get()
543
544    assert (
545        wait_for_record(
546            r'\[error\].+the application returned not an iterable object'
547        )
548        is not None
549    ), 'not iterable'
550
551
552def test_python_application_write():
553    client.load('write')
554
555    assert client.get()['body'] == '0123456789', 'write'
556
557
558def test_python_application_encoding():
559    client.load('encoding')
560
561    try:
562        locales = (
563            subprocess.check_output(
564                ['locale', '-a'],
565                stderr=subprocess.STDOUT,
566            )
567            .decode()
568            .splitlines()
569        )
570    except (
571        FileNotFoundError,
572        UnicodeDecodeError,
573        subprocess.CalledProcessError,
574    ):
575        pytest.skip('require locale')
576
577    to_check = [
578        re.compile(r'.*UTF[-_]?8'),
579        re.compile(r'.*ISO[-_]?8859[-_]?1'),
580    ]
581    matches = [
582        loc
583        for loc in locales
584        if any(pattern.match(loc.upper()) for pattern in to_check)
585    ]
586
587    if not matches:
588        pytest.skip('no available locales')
589
590    def unify(enc):
591        enc.upper().replace('-', '').replace('_', '')
592
593    for loc in matches:
594        assert 'success' in client.conf(
595            {"LC_CTYPE": loc, "LC_ALL": ""},
596            '/config/applications/encoding/environment',
597        )
598        resp = client.get()
599        assert resp['status'] == 200, 'status'
600        assert unify(resp['headers']['X-Encoding']) == unify(loc.split('.')[-1])
601
602
603def test_python_application_unicode(temp_dir):
604    try:
605        app_type = client.get_application_type()
606        v = version.Version(app_type.split()[-1])
607        if v.major != 3:
608            raise version.InvalidVersion
609
610    except version.InvalidVersion:
611        pytest.skip('require python module version 3')
612
613    venv_path = f'{temp_dir}/venv'
614    venv.create(venv_path)
615
616    client.load('unicode')
617    assert 'success' in client.conf(
618        f'"{venv_path}"',
619        '/config/applications/unicode/home',
620    )
621    assert (
622        client.get(
623            headers={
624                'Host': 'localhost',
625                'Temp-dir': temp_dir,
626                'Connection': 'close',
627            }
628        )['status']
629        == 200
630    )
631
632
633def test_python_application_threading(wait_for_record):
634    """wait_for_record() timeouts after 5s while every thread works at
635    least 3s.  So without releasing GIL test should fail.
636    """
637
638    client.load('threading')
639
640    for _ in range(10):
641        client.get(no_recv=True)
642
643    assert (
644        wait_for_record(r'\(5\) Thread: 100', wait=50) is not None
645    ), 'last thread finished'
646
647
648def test_python_application_iter_exception(findall, wait_for_record):
649    client.load('iter_exception')
650
651    # Default request doesn't lead to the exception.
652
653    resp = client.get(
654        headers={
655            'Host': 'localhost',
656            'X-Skip': '9',
657            'X-Chunked': '1',
658            'Connection': 'close',
659        }
660    )
661    assert resp['status'] == 200, 'status'
662    assert resp['body'] == 'XXXXXXX', 'body'
663
664    # Exception before start_response().
665
666    assert client.get()['status'] == 503, 'error'
667
668    assert wait_for_record(r'Traceback') is not None, 'traceback'
669    assert (
670        wait_for_record(r"raise Exception\('first exception'\)") is not None
671    ), 'first exception raise'
672    assert len(findall(r'Traceback')) == 1, 'traceback count 1'
673
674    # Exception after start_response(), before first write().
675
676    assert (
677        client.get(
678            headers={
679                'Host': 'localhost',
680                'X-Skip': '1',
681                'Connection': 'close',
682            }
683        )['status']
684        == 503
685    ), 'error 2'
686
687    assert (
688        wait_for_record(r"raise Exception\('second exception'\)") is not None
689    ), 'exception raise second'
690    assert len(findall(r'Traceback')) == 2, 'traceback count 2'
691
692    # Exception after first write(), before first __next__().
693
694    _, sock = client.get(
695        headers={
696            'Host': 'localhost',
697            'X-Skip': '2',
698            'Connection': 'keep-alive',
699        },
700        start=True,
701    )
702
703    assert (
704        wait_for_record(r"raise Exception\('third exception'\)") is not None
705    ), 'exception raise third'
706    assert len(findall(r'Traceback')) == 3, 'traceback count 3'
707
708    assert client.get(sock=sock) == {}, 'closed connection'
709
710    # Exception after first write(), before first __next__(),
711    # chunked (incomplete body).
712
713    resp = client.get(
714        headers={
715            'Host': 'localhost',
716            'X-Skip': '2',
717            'X-Chunked': '1',
718            'Connection': 'close',
719        },
720        raw_resp=True,
721    )
722    if resp:
723        assert resp[-5:] != '0\r\n\r\n', 'incomplete body'
724    assert len(findall(r'Traceback')) == 4, 'traceback count 4'
725
726    # Exception in __next__().
727
728    _, sock = client.get(
729        headers={
730            'Host': 'localhost',
731            'X-Skip': '3',
732            'Connection': 'keep-alive',
733        },
734        start=True,
735    )
736
737    assert (
738        wait_for_record(r"raise Exception\('next exception'\)") is not None
739    ), 'exception raise next'
740    assert len(findall(r'Traceback')) == 5, 'traceback count 5'
741
742    assert client.get(sock=sock) == {}, 'closed connection 2'
743
744    # Exception in __next__(), chunked (incomplete body).
745
746    resp = client.get(
747        headers={
748            'Host': 'localhost',
749            'X-Skip': '3',
750            'X-Chunked': '1',
751            'Connection': 'close',
752        },
753        raw_resp=True,
754    )
755    if resp:
756        assert resp[-5:] != '0\r\n\r\n', 'incomplete body 2'
757    assert len(findall(r'Traceback')) == 6, 'traceback count 6'
758
759    # Exception before start_response() and in close().
760
761    assert (
762        client.get(
763            headers={
764                'Host': 'localhost',
765                'X-Not-Skip-Close': '1',
766                'Connection': 'close',
767            }
768        )['status']
769        == 503
770    ), 'error'
771
772    assert (
773        wait_for_record(r"raise Exception\('close exception'\)") is not None
774    ), 'exception raise close'
775    assert len(findall(r'Traceback')) == 8, 'traceback count 8'
776
777
778def test_python_user_group(require):
779    require({'privileged_user': True})
780
781    nobody_uid = pwd.getpwnam('nobody').pw_uid
782
783    group = 'nobody'
784
785    try:
786        group_id = grp.getgrnam(group).gr_gid
787    except KeyError:
788        group = 'nogroup'
789        group_id = grp.getgrnam(group).gr_gid
790
791    client.load('user_group')
792
793    obj = client.getjson()['body']
794    assert obj['UID'] == nobody_uid, 'nobody uid'
795    assert obj['GID'] == group_id, 'nobody gid'
796
797    client.load('user_group', user='nobody')
798
799    obj = client.getjson()['body']
800    assert obj['UID'] == nobody_uid, 'nobody uid user=nobody'
801    assert obj['GID'] == group_id, 'nobody gid user=nobody'
802
803    client.load('user_group', user='nobody', group=group)
804
805    obj = client.getjson()['body']
806    assert obj['UID'] == nobody_uid, f'nobody uid user=nobody group={group}'
807    assert obj['GID'] == group_id, f'nobody gid user=nobody group={group}'
808
809    client.load('user_group', group=group)
810
811    obj = client.getjson()['body']
812    assert obj['UID'] == nobody_uid, f'nobody uid group={group}'
813    assert obj['GID'] == group_id, f'nobody gid group={group}'
814
815    client.load('user_group', user='root')
816
817    obj = client.getjson()['body']
818    assert obj['UID'] == 0, 'root uid user=root'
819    assert obj['GID'] == 0, 'root gid user=root'
820
821    group = 'root'
822
823    try:
824        grp.getgrnam(group)
825        group = True
826    except KeyError:
827        group = False
828
829    if group:
830        client.load('user_group', user='root', group='root')
831
832        obj = client.getjson()['body']
833        assert obj['UID'] == 0, 'root uid user=root group=root'
834        assert obj['GID'] == 0, 'root gid user=root group=root'
835
836        client.load('user_group', group='root')
837
838        obj = client.getjson()['body']
839        assert obj['UID'] == nobody_uid, 'root uid group=root'
840        assert obj['GID'] == 0, 'root gid group=root'
841
842
843def test_python_application_callable(skip_alert):
844    skip_alert(r'Python failed to get "blah" from module')
845    client.load('callable')
846
847    assert client.get()['status'] == 204, 'default application response'
848
849    client.load('callable', callable="app")
850
851    assert client.get()['status'] == 200, 'callable response'
852
853    client.load('callable', callable="blah")
854
855    assert client.get()['status'] not in [200, 204], 'callable response inv'
856
857
858def test_python_application_path():
859    client.load('path')
860
861    def set_path(path):
862        assert 'success' in client.conf(path, 'applications/path/path')
863
864    def get_path():
865        return client.get()['body'].split(os.pathsep)
866
867    default_path = client.conf_get('/config/applications/path/path')
868    assert 'success' in client.conf(
869        {"PYTHONPATH": default_path},
870        '/config/applications/path/environment',
871    )
872
873    client.conf_delete('/config/applications/path/path')
874    sys_path = get_path()
875
876    set_path('"/blah"')
877    assert ['/blah', *sys_path] == get_path(), 'check path'
878
879    set_path('"/new"')
880    assert ['/new', *sys_path] == get_path(), 'check path update'
881
882    set_path('["/blah1", "/blah2"]')
883    assert [
884        '/blah1',
885        '/blah2',
886        *sys_path,
887    ] == get_path(), 'check path array'
888
889
890def test_python_application_path_invalid():
891    client.load('path')
892
893    def check_path(path):
894        assert 'error' in client.conf(path, 'applications/path/path')
895
896    check_path('{}')
897    check_path('["/blah", []]')
898
899
900def test_python_application_threads():
901    client.load('threads', threads=4)
902
903    socks = []
904
905    for _ in range(4):
906        sock = client.get(
907            headers={
908                'Host': 'localhost',
909                'X-Delay': '2',
910                'Connection': 'close',
911            },
912            no_recv=True,
913        )
914
915        socks.append(sock)
916
917    threads = set()
918
919    for sock in socks:
920        resp = client.recvall(sock).decode('utf-8')
921
922        client.log_in(resp)
923
924        resp = client._resp_to_dict(resp)
925
926        assert resp['status'] == 200, 'status'
927
928        threads.add(resp['headers']['X-Thread'])
929
930        assert resp['headers']['Wsgi-Multithread'] == 'True', 'multithread'
931
932        sock.close()
933
934    assert len(socks) == len(threads), 'threads differs'
935