| 1 | module http |
| 2 | |
| 3 | // Tests for the connection-pooling Transport (transport.v): keep-alive reuse |
| 4 | // over plain TCP and TLS, no-reuse on `Connection: close` / truncated reads, |
| 5 | // transparent retry on stale pooled connections, idle eviction, and the |
| 6 | // `disable_connection_reuse` opt-out. Each test runs its own loopback server |
| 7 | // with an accept counter: the accept count is the proof of (non-)reuse. |
| 8 | import net |
| 9 | import net.mbedtls |
| 10 | import sync |
| 11 | import time |
| 12 | |
| 13 | const tls_test_cert_path = @VEXEROOT + |
| 14 | '/vlib/net/websocket/tests/autobahn/fuzzing_server_wss/config/server.crt' |
| 15 | const tls_test_key_path = @VEXEROOT + |
| 16 | '/vlib/net/websocket/tests/autobahn/fuzzing_server_wss/config/server.key' |
| 17 | |
| 18 | @[heap] |
| 19 | struct KaSrv { |
| 20 | mut: |
| 21 | mu &sync.Mutex = sync.new_mutex() |
| 22 | accepts int |
| 23 | // connection_close: respond with `Connection: close` and close afterwards. |
| 24 | connection_close bool |
| 25 | // close_after_each: close after each response WITHOUT advertising it — the |
| 26 | // classic stale-pooled-connection scenario. |
| 27 | close_after_each bool |
| 28 | // split_connection_close: emit the close token in a SECOND, repeated |
| 29 | // `Connection` header (`keep-alive` first, then `close`) while keeping the |
| 30 | // socket open — a server that says "do not reuse" via a split field. The |
| 31 | // client must honor it and not pool, even though the connection stays usable. |
| 32 | split_connection_close bool |
| 33 | // drop_on_post: after reading a POST request head, close the connection |
| 34 | // without responding — a reused-connection failure after the bytes were |
| 35 | // written, for a non-idempotent method. |
| 36 | drop_on_post bool |
| 37 | posts int // POST request heads actually read by the server |
| 38 | body string = 'hello' |
| 39 | } |
| 40 | |
| 41 | fn (mut s KaSrv) bump_accepts() { |
| 42 | s.mu.lock() |
| 43 | s.accepts++ |
| 44 | s.mu.unlock() |
| 45 | } |
| 46 | |
| 47 | fn (mut s KaSrv) accept_count() int { |
| 48 | s.mu.lock() |
| 49 | defer { |
| 50 | s.mu.unlock() |
| 51 | } |
| 52 | return s.accepts |
| 53 | } |
| 54 | |
| 55 | fn (mut s KaSrv) post_count() int { |
| 56 | s.mu.lock() |
| 57 | defer { |
| 58 | s.mu.unlock() |
| 59 | } |
| 60 | return s.posts |
| 61 | } |
| 62 | |
| 63 | // ka_read_request_head reads from `conn` until a full request head (terminated |
| 64 | // by a blank line) has arrived, returning it. The test requests carry no bodies. |
| 65 | fn ka_read_request_head(mut conn net.TcpConn) !string { |
| 66 | mut buf := []u8{len: 4096} |
| 67 | mut sofar := []u8{} |
| 68 | for { |
| 69 | n := conn.read(mut buf)! |
| 70 | if n <= 0 { |
| 71 | return error('closed') |
| 72 | } |
| 73 | sofar << buf[..n] |
| 74 | if sofar.bytestr().contains('\r\n\r\n') { |
| 75 | return sofar.bytestr() |
| 76 | } |
| 77 | } |
| 78 | return error('closed') |
| 79 | } |
| 80 | |
| 81 | fn ka_srv_serve_conn(mut s KaSrv, mut conn net.TcpConn) { |
| 82 | defer { |
| 83 | conn.close() or {} |
| 84 | } |
| 85 | conn.set_read_timeout(10 * time.second) |
| 86 | for { |
| 87 | head := ka_read_request_head(mut conn) or { return } |
| 88 | if s.drop_on_post && head.starts_with('POST ') { |
| 89 | s.mu.lock() |
| 90 | s.posts++ |
| 91 | s.mu.unlock() |
| 92 | // Drop the connection without responding: the request bytes were |
| 93 | // written, so this is not a stale-write. |
| 94 | return |
| 95 | } |
| 96 | mut resp := 'HTTP/1.1 200 OK\r\nContent-Length: ${s.body.len}\r\n' |
| 97 | if s.connection_close { |
| 98 | resp += 'Connection: close\r\n' |
| 99 | } |
| 100 | if s.split_connection_close { |
| 101 | resp += 'Connection: keep-alive\r\nConnection: close\r\n' |
| 102 | } |
| 103 | resp += '\r\n' + s.body |
| 104 | conn.write(resp.bytes()) or { return } |
| 105 | if s.connection_close || s.close_after_each { |
| 106 | return |
| 107 | } |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | fn ka_srv_loop(mut s KaSrv, mut listener net.TcpListener) { |
| 112 | for { |
| 113 | mut conn := listener.accept() or { return } |
| 114 | s.bump_accepts() |
| 115 | ka_srv_serve_conn(mut s, mut conn) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // start_ka_srv starts a keep-alive capable loopback HTTP server, returning its |
| 120 | // port, listener and server thread. |
| 121 | fn start_ka_srv(mut s KaSrv) !(int, &net.TcpListener, thread) { |
| 122 | mut listener := net.listen_tcp(.ip, '127.0.0.1:0')! |
| 123 | port := listener.addr()!.port()! |
| 124 | th := spawn ka_srv_loop(mut s, mut listener) |
| 125 | return port, listener, th |
| 126 | } |
| 127 | |
| 128 | // stop_ka_srv tears a test server down fully before the test returns: closing |
| 129 | // the idle pool unblocks a server thread reading a kept-alive connection, |
| 130 | // closing the listener aborts its accept, and the join guarantees the thread |
| 131 | // is gone before the next test creates sockets (otherwise the OS can recycle |
| 132 | // this listener's handle for the next test's listener while the old thread is |
| 133 | // still calling accept on it, stealing its connections). |
| 134 | fn stop_ka_srv(mut listener net.TcpListener, th thread) { |
| 135 | close_idle_connections() |
| 136 | listener.close() or {} |
| 137 | th.wait() |
| 138 | } |
| 139 | |
| 140 | // The pool key must isolate distinct TLS configurations even when a field value |
| 141 | // contains the '|' separator, or a request could reuse a connection dialed with |
| 142 | // the wrong cert/CA. cert='a|b',cert_key='c' and cert='a',cert_key='b|c' must |
| 143 | // not collide. |
| 144 | fn test_transport_pool_key_no_delimiter_collision() { |
| 145 | a := Request{ |
| 146 | cert: 'a|b' |
| 147 | cert_key: 'c' |
| 148 | } |
| 149 | b := Request{ |
| 150 | cert: 'a' |
| 151 | cert_key: 'b|c' |
| 152 | } |
| 153 | assert transport_pool_key(a, 'https', 'h', 443) != transport_pool_key(b, 'https', 'h', 443) |
| 154 | // Identical configs still share a key. |
| 155 | a2 := Request{ |
| 156 | cert: 'a|b' |
| 157 | cert_key: 'c' |
| 158 | } |
| 159 | assert transport_pool_key(a, 'https', 'h', 443) == transport_pool_key(a2, 'https', 'h', 443) |
| 160 | // A host containing '|' must not collide with a different host/port split. |
| 161 | assert transport_pool_key(Request{}, 'https', 'h|x', 443) != transport_pool_key(Request{}, |
| 162 | 'https', 'h', 443) |
| 163 | } |
| 164 | |
| 165 | fn test_h1_plain_reuse() { |
| 166 | mut srv := &KaSrv{} |
| 167 | port, mut listener, th := start_ka_srv(mut srv) or { |
| 168 | assert false, 'server: ${err}' |
| 169 | return |
| 170 | } |
| 171 | for i in 0 .. 3 { |
| 172 | resp := fetch(url: 'http://127.0.0.1:${port}/r${i}') or { |
| 173 | assert false, 'fetch ${i}: ${err}' |
| 174 | return |
| 175 | } |
| 176 | assert resp.status_code == 200 |
| 177 | assert resp.body == 'hello' |
| 178 | } |
| 179 | // All three requests must have shared one connection. |
| 180 | assert srv.accept_count() == 1 |
| 181 | stop_ka_srv(mut listener, th) |
| 182 | } |
| 183 | |
| 184 | fn test_h1_no_reuse_on_connection_close_response() { |
| 185 | mut srv := &KaSrv{ |
| 186 | connection_close: true |
| 187 | } |
| 188 | port, mut listener, th := start_ka_srv(mut srv) or { |
| 189 | assert false, 'server: ${err}' |
| 190 | return |
| 191 | } |
| 192 | for i in 0 .. 2 { |
| 193 | resp := fetch(url: 'http://127.0.0.1:${port}/r${i}') or { |
| 194 | assert false, 'fetch ${i}: ${err}' |
| 195 | return |
| 196 | } |
| 197 | assert resp.status_code == 200 |
| 198 | } |
| 199 | // `Connection: close` responses must not be pooled. |
| 200 | assert srv.accept_count() == 2 |
| 201 | stop_ka_srv(mut listener, th) |
| 202 | } |
| 203 | |
| 204 | // A close token carried in a repeated `Connection` header (after a keep-alive |
| 205 | // one) must still be honored: parse_headers stores repeats separately and get() |
| 206 | // returns only the first, so response_allows_reuse joins all values. The server |
| 207 | // keeps the socket open, so a wrongly-pooled connection would be reused (1 |
| 208 | // accept); honoring the close dials fresh each time (2 accepts). |
| 209 | fn test_h1_no_reuse_on_split_connection_close() { |
| 210 | mut srv := &KaSrv{ |
| 211 | split_connection_close: true |
| 212 | } |
| 213 | port, mut listener, th := start_ka_srv(mut srv) or { |
| 214 | assert false, 'server: ${err}' |
| 215 | return |
| 216 | } |
| 217 | for i in 0 .. 2 { |
| 218 | resp := fetch(url: 'http://127.0.0.1:${port}/r${i}') or { |
| 219 | assert false, 'fetch ${i}: ${err}' |
| 220 | return |
| 221 | } |
| 222 | assert resp.status_code == 200 |
| 223 | } |
| 224 | assert srv.accept_count() == 2 |
| 225 | stop_ka_srv(mut listener, th) |
| 226 | } |
| 227 | |
| 228 | // The total idle pool is bounded by max_idle_conns across all pool keys, not |
| 229 | // just per host: checking in more distinct-keyed connections than the cap |
| 230 | // evicts the least-recently-used ones (here k0, k1) and keeps the newest. |
| 231 | fn test_transport_global_idle_cap() { |
| 232 | mut t := new_transport() |
| 233 | t.max_idle_conns = 3 |
| 234 | t.max_idle_conns_per_host = 10 // keep the per-host cap out of the way |
| 235 | for i in 0 .. 5 { |
| 236 | mut c := &H1PooledConn{ |
| 237 | key: 'k${i}' |
| 238 | } |
| 239 | t.checkin(mut c) |
| 240 | } |
| 241 | mut total := 0 |
| 242 | for _, list in t.h1_idle { |
| 243 | total += list.len |
| 244 | } |
| 245 | assert total == 3, 'global idle cap not enforced: ${total}' |
| 246 | assert 'k0' !in t.h1_idle |
| 247 | assert 'k1' !in t.h1_idle |
| 248 | assert 'k2' in t.h1_idle |
| 249 | assert 'k3' in t.h1_idle |
| 250 | assert 'k4' in t.h1_idle |
| 251 | } |
| 252 | |
| 253 | fn test_h1_stale_pooled_connection_is_retried() { |
| 254 | mut srv := &KaSrv{ |
| 255 | close_after_each: true |
| 256 | } |
| 257 | port, mut listener, th := start_ka_srv(mut srv) or { |
| 258 | assert false, 'server: ${err}' |
| 259 | return |
| 260 | } |
| 261 | // First request succeeds and the connection is pooled (the response did not |
| 262 | // advertise the close). The server then closes it. The second request picks |
| 263 | // up the stale connection, fails, and must transparently retry on a fresh |
| 264 | // one. |
| 265 | for i in 0 .. 2 { |
| 266 | resp := fetch(url: 'http://127.0.0.1:${port}/r${i}') or { |
| 267 | assert false, 'fetch ${i}: ${err}' |
| 268 | return |
| 269 | } |
| 270 | assert resp.status_code == 200 |
| 271 | assert resp.body == 'hello' |
| 272 | } |
| 273 | assert srv.accept_count() == 2 |
| 274 | stop_ka_srv(mut listener, th) |
| 275 | } |
| 276 | |
| 277 | // A non-idempotent request that fails on a reused keep-alive connection after |
| 278 | // its bytes were written must NOT be replayed: doing so could duplicate side |
| 279 | // effects. The server reads the POST then drops the connection; the client must |
| 280 | // surface the error without retrying on a fresh connection. |
| 281 | fn test_h1_unsafe_pooled_post_is_not_retried() { |
| 282 | mut srv := &KaSrv{ |
| 283 | drop_on_post: true |
| 284 | } |
| 285 | port, mut listener, th := start_ka_srv(mut srv) or { |
| 286 | assert false, 'server: ${err}' |
| 287 | return |
| 288 | } |
| 289 | // First, a GET to establish and pool a keep-alive connection. |
| 290 | resp := fetch(url: 'http://127.0.0.1:${port}/warmup') or { |
| 291 | assert false, 'warmup fetch: ${err}' |
| 292 | return |
| 293 | } |
| 294 | assert resp.status_code == 200 |
| 295 | // The POST reuses that connection; the server reads it and drops the |
| 296 | // connection. The request must fail rather than be re-sent. |
| 297 | fetch(method: .post, url: 'http://127.0.0.1:${port}/submit', data: 'payload') or { |
| 298 | // expected: the POST failed and was not retried. |
| 299 | assert srv.post_count() == 1, 'POST was replayed ${srv.post_count()} times' |
| 300 | assert srv.accept_count() == 1, 'a fresh connection was opened to retry the POST' |
| 301 | stop_ka_srv(mut listener, th) |
| 302 | return |
| 303 | } |
| 304 | assert false, 'the POST unexpectedly succeeded' |
| 305 | stop_ka_srv(mut listener, th) |
| 306 | } |
| 307 | |
| 308 | fn test_h1_opt_out_disables_reuse() { |
| 309 | mut srv := &KaSrv{} |
| 310 | port, mut listener, th := start_ka_srv(mut srv) or { |
| 311 | assert false, 'server: ${err}' |
| 312 | return |
| 313 | } |
| 314 | for i in 0 .. 2 { |
| 315 | resp := fetch( |
| 316 | url: 'http://127.0.0.1:${port}/r${i}' |
| 317 | disable_connection_reuse: true |
| 318 | ) or { |
| 319 | assert false, 'fetch ${i}: ${err}' |
| 320 | return |
| 321 | } |
| 322 | assert resp.status_code == 200 |
| 323 | } |
| 324 | // Opt-out requests open one connection each. |
| 325 | assert srv.accept_count() == 2 |
| 326 | stop_ka_srv(mut listener, th) |
| 327 | } |
| 328 | |
| 329 | fn test_h1_truncated_read_poisons_connection() { |
| 330 | mut srv := &KaSrv{ |
| 331 | // Larger than the 64KB read buffer, so the body needs several reads and |
| 332 | // the stop limit actually interrupts the transfer mid-stream. |
| 333 | body: 'x'.repeat(200 * 1024) |
| 334 | } |
| 335 | port, mut listener, th := start_ka_srv(mut srv) or { |
| 336 | assert false, 'server: ${err}' |
| 337 | return |
| 338 | } |
| 339 | // A stop_receiving_limit read leaves unread response bytes on the wire, so |
| 340 | // that connection must not be reused. |
| 341 | resp1 := fetch(url: 'http://127.0.0.1:${port}/big', stop_receiving_limit: 1000) or { |
| 342 | assert false, 'fetch 1: ${err}' |
| 343 | return |
| 344 | } |
| 345 | assert resp1.status_code == 200 |
| 346 | resp2 := fetch(url: 'http://127.0.0.1:${port}/after') or { |
| 347 | assert false, 'fetch 2: ${err}' |
| 348 | return |
| 349 | } |
| 350 | assert resp2.status_code == 200 |
| 351 | assert srv.accept_count() == 2 |
| 352 | stop_ka_srv(mut listener, th) |
| 353 | } |
| 354 | |
| 355 | fn test_h1_idle_eviction() { |
| 356 | mut srv := &KaSrv{} |
| 357 | port, mut listener, th := start_ka_srv(mut srv) or { |
| 358 | assert false, 'server: ${err}' |
| 359 | return |
| 360 | } |
| 361 | mut t := new_transport() |
| 362 | t.idle_timeout = 50 * time.millisecond |
| 363 | req := prepare(url: 'http://127.0.0.1:${port}/') or { |
| 364 | assert false, 'prepare: ${err}' |
| 365 | return |
| 366 | } |
| 367 | r1 := t.round_trip(req, .get, 'http', '127.0.0.1', port, '/', '', req.header) or { |
| 368 | assert false, 'round_trip 1: ${err}' |
| 369 | return |
| 370 | } |
| 371 | assert r1.status_code == 200 |
| 372 | time.sleep(150 * time.millisecond) |
| 373 | // The pooled connection has sat idle past the timeout: it must be evicted |
| 374 | // and a fresh one dialled. |
| 375 | r2 := t.round_trip(req, .get, 'http', '127.0.0.1', port, '/', '', req.header) or { |
| 376 | assert false, 'round_trip 2: ${err}' |
| 377 | return |
| 378 | } |
| 379 | assert r2.status_code == 200 |
| 380 | assert srv.accept_count() == 2 |
| 381 | // This test pools in its own private Transport, so flush that one before |
| 382 | // the joint teardown. |
| 383 | t.close_idle() |
| 384 | stop_ka_srv(mut listener, th) |
| 385 | } |
| 386 | |
| 387 | // --- TLS (h1 over mbedtls) reuse --- |
| 388 | |
| 389 | @[heap] |
| 390 | struct TlsKaSrv { |
| 391 | mut: |
| 392 | mu &sync.Mutex = sync.new_mutex() |
| 393 | accepts int |
| 394 | } |
| 395 | |
| 396 | fn (mut s TlsKaSrv) bump_accepts() { |
| 397 | s.mu.lock() |
| 398 | s.accepts++ |
| 399 | s.mu.unlock() |
| 400 | } |
| 401 | |
| 402 | fn (mut s TlsKaSrv) accept_count() int { |
| 403 | s.mu.lock() |
| 404 | defer { |
| 405 | s.mu.unlock() |
| 406 | } |
| 407 | return s.accepts |
| 408 | } |
| 409 | |
| 410 | fn tls_ka_srv_serve_conn(mut conn mbedtls.SSLConn) { |
| 411 | defer { |
| 412 | conn.shutdown() or {} |
| 413 | } |
| 414 | body := 'tls hello' |
| 415 | for { |
| 416 | mut buf := []u8{len: 4096} |
| 417 | mut sofar := []u8{} |
| 418 | for !sofar.bytestr().contains('\r\n\r\n') { |
| 419 | n := conn.read(mut buf) or { return } |
| 420 | if n <= 0 { |
| 421 | return |
| 422 | } |
| 423 | sofar << buf[..n] |
| 424 | } |
| 425 | conn.write_string('HTTP/1.1 200 OK\r\nContent-Length: ${body.len}\r\n\r\n${body}') or { |
| 426 | return |
| 427 | } |
| 428 | } |
| 429 | } |
| 430 | |
| 431 | fn tls_ka_srv_loop(mut s TlsKaSrv, mut listener mbedtls.SSLListener) { |
| 432 | for { |
| 433 | mut conn := listener.accept() or { return } |
| 434 | s.bump_accepts() |
| 435 | // Serve each connection on its own thread: a pooled idle connection |
| 436 | // keeps its serve loop parked in read, which must not block accepting |
| 437 | // the next connection. (The serve threads exit when the test closes |
| 438 | // the pooled connections via close_idle_connections.) |
| 439 | spawn tls_ka_srv_serve_conn(mut conn) |
| 440 | } |
| 441 | } |
| 442 | |
| 443 | fn test_h1_tls_reuse() { |
| 444 | $if windows && !no_vschannel ? { |
| 445 | // The default Windows TLS backend (SChannel) keeps its one-shot path |
| 446 | // until SChannel pooling lands. |
| 447 | eprintln('skipping: SChannel connection pooling is not implemented yet') |
| 448 | return |
| 449 | } |
| 450 | mut port_listener := net.listen_tcp(.ip, '127.0.0.1:0') or { |
| 451 | assert false, 'port: ${err}' |
| 452 | return |
| 453 | } |
| 454 | port := port_listener.addr() or { |
| 455 | assert false, 'addr: ${err}' |
| 456 | return |
| 457 | }.port() or { |
| 458 | assert false, 'port: ${err}' |
| 459 | return |
| 460 | } |
| 461 | port_listener.close() or {} |
| 462 | mut listener := mbedtls.new_ssl_listener('127.0.0.1:${port}', mbedtls.SSLConnectConfig{ |
| 463 | cert: tls_test_cert_path |
| 464 | cert_key: tls_test_key_path |
| 465 | validate: false |
| 466 | }) or { |
| 467 | assert false, 'listener: ${err}' |
| 468 | return |
| 469 | } |
| 470 | mut srv := &TlsKaSrv{} |
| 471 | th := spawn tls_ka_srv_loop(mut srv, mut listener) |
| 472 | for i in 0 .. 2 { |
| 473 | resp := fetch(url: 'https://127.0.0.1:${port}/r${i}', validate: false) or { |
| 474 | assert false, 'fetch ${i}: ${err}' |
| 475 | return |
| 476 | } |
| 477 | assert resp.status_code == 200 |
| 478 | assert resp.body == 'tls hello' |
| 479 | } |
| 480 | // Both https requests must have shared one TLS connection. |
| 481 | assert srv.accept_count() == 1 |
| 482 | // Free the pooled TLS connection (unblocking the server read), then abort |
| 483 | // the accept and join the server thread before the test returns. |
| 484 | close_idle_connections() |
| 485 | listener.shutdown() or {} |
| 486 | th.wait() |
| 487 | } |
| 488 | |
| 489 | // A TLS connection dialled with HTTP/2 disabled (no ALPN) must not satisfy an |
| 490 | // HTTP/2-enabled request to the same origin — the ALPN preference is part of |
| 491 | // the pool key. Forced-h1 requests still share among themselves. |
| 492 | fn test_h1_tls_no_reuse_across_alpn_preference() { |
| 493 | $if windows && !no_vschannel ? { |
| 494 | eprintln('skipping: SChannel connection pooling is not implemented yet') |
| 495 | return |
| 496 | } |
| 497 | mut port_listener := net.listen_tcp(.ip, '127.0.0.1:0') or { |
| 498 | assert false, 'port: ${err}' |
| 499 | return |
| 500 | } |
| 501 | port := port_listener.addr() or { |
| 502 | assert false, 'addr: ${err}' |
| 503 | return |
| 504 | }.port() or { |
| 505 | assert false, 'port: ${err}' |
| 506 | return |
| 507 | } |
| 508 | port_listener.close() or {} |
| 509 | mut listener := mbedtls.new_ssl_listener('127.0.0.1:${port}', mbedtls.SSLConnectConfig{ |
| 510 | cert: tls_test_cert_path |
| 511 | cert_key: tls_test_key_path |
| 512 | validate: false |
| 513 | }) or { |
| 514 | assert false, 'listener: ${err}' |
| 515 | return |
| 516 | } |
| 517 | mut srv := &TlsKaSrv{} |
| 518 | th := spawn tls_ka_srv_loop(mut srv, mut listener) |
| 519 | // 1. Forced-HTTP/1.1 request: dialled without ALPN, pooled under its key. |
| 520 | r1 := fetch(url: 'https://127.0.0.1:${port}/h1', validate: false, enable_http2: false) or { |
| 521 | assert false, 'fetch 1: ${err}' |
| 522 | return |
| 523 | } |
| 524 | assert r1.status_code == 200 |
| 525 | // 2. Default (HTTP/2-enabled) request: must NOT reuse the no-ALPN |
| 526 | // connection; it dials fresh and advertises h2. |
| 527 | r2 := fetch(url: 'https://127.0.0.1:${port}/h2pref', validate: false) or { |
| 528 | assert false, 'fetch 2: ${err}' |
| 529 | return |
| 530 | } |
| 531 | assert r2.status_code == 200 |
| 532 | assert srv.accept_count() == 2 |
| 533 | // 3. Another forced-HTTP/1.1 request: reuses connection 1. |
| 534 | r3 := fetch(url: 'https://127.0.0.1:${port}/h1again', validate: false, enable_http2: false) or { |
| 535 | assert false, 'fetch 3: ${err}' |
| 536 | return |
| 537 | } |
| 538 | assert r3.status_code == 200 |
| 539 | assert srv.accept_count() == 2 |
| 540 | close_idle_connections() |
| 541 | listener.shutdown() or {} |
| 542 | th.wait() |
| 543 | } |
| 544 | |