| 1 | module http |
| 2 | |
| 3 | // Tests for the synchronous HTTP/2 client connection, driven over an in-memory |
| 4 | // transport so no socket is required. |
| 5 | |
| 6 | // MockTransport plays a fixed script of server->client bytes and records what |
| 7 | // the client writes. |
| 8 | struct MockTransport { |
| 9 | mut: |
| 10 | inbound []u8 // server -> client; the client reads these |
| 11 | rpos int |
| 12 | outbound []u8 // client -> server; what the client wrote |
| 13 | } |
| 14 | |
| 15 | fn (mut m MockTransport) read(mut buf []u8) !int { |
| 16 | if m.rpos >= m.inbound.len { |
| 17 | return error('eof') |
| 18 | } |
| 19 | mut n := m.inbound.len - m.rpos |
| 20 | if n > buf.len { |
| 21 | n = buf.len |
| 22 | } |
| 23 | for i in 0 .. n { |
| 24 | buf[i] = m.inbound[m.rpos + i] |
| 25 | } |
| 26 | m.rpos += n |
| 27 | return n |
| 28 | } |
| 29 | |
| 30 | fn (mut m MockTransport) write(buf []u8) !int { |
| 31 | m.outbound << buf |
| 32 | return buf.len |
| 33 | } |
| 34 | |
| 35 | // build_server_stream encodes a server-side response on stream 1: SETTINGS, an |
| 36 | // ACK of our SETTINGS, a HEADERS frame, and optional DATA frames. |
| 37 | fn build_server_stream(resp_fields []H2HeaderField, body_chunks [][]u8) []u8 { |
| 38 | mut senc := H2HpackEncoder{} |
| 39 | hdr := senc.encode(resp_fields) |
| 40 | mut out := []u8{} |
| 41 | out << H2Frame(H2SettingsFrame{}).encode() |
| 42 | out << H2Frame(H2SettingsFrame{ |
| 43 | ack: true |
| 44 | }).encode() |
| 45 | last_is_headers := body_chunks.len == 0 |
| 46 | out << H2Frame(H2HeadersFrame{ |
| 47 | stream_id: 1 |
| 48 | fragment: hdr |
| 49 | end_headers: true |
| 50 | end_stream: last_is_headers |
| 51 | }).encode() |
| 52 | for i, chunk in body_chunks { |
| 53 | out << H2Frame(H2DataFrame{ |
| 54 | stream_id: 1 |
| 55 | data: chunk |
| 56 | end_stream: i == body_chunks.len - 1 |
| 57 | }).encode() |
| 58 | } |
| 59 | return out |
| 60 | } |
| 61 | |
| 62 | fn test_h2_conn_basic_get() { |
| 63 | inbound := build_server_stream([ |
| 64 | H2HeaderField{':status', '200'}, |
| 65 | H2HeaderField{'content-type', 'text/plain'}, |
| 66 | ], [' hello'.bytes()]) |
| 67 | mut t := &MockTransport{ |
| 68 | inbound: inbound |
| 69 | } |
| 70 | mut c := new_h2_conn(t) |
| 71 | resp := c.do(H2ClientRequest{ |
| 72 | method: 'GET' |
| 73 | authority: 'example.com' |
| 74 | path: '/' |
| 75 | })! |
| 76 | assert resp.status == 200 |
| 77 | assert resp.body.bytestr() == ' hello' |
| 78 | assert resp.headers.any(it.name == 'content-type' && it.value == 'text/plain') |
| 79 | |
| 80 | // The client must open with the connection preface. |
| 81 | assert t.outbound.len > h2_client_preface.len |
| 82 | assert t.outbound[..h2_client_preface.len].bytestr() == h2_client_preface |
| 83 | |
| 84 | // First client frame after the preface is its SETTINGS. |
| 85 | f1, n1 := h2_read_frame(t.outbound[h2_client_preface.len..])! |
| 86 | assert f1 is H2SettingsFrame |
| 87 | |
| 88 | // Next is the request HEADERS on stream 1; decode and check pseudo-headers. |
| 89 | f2, _ := h2_read_frame(t.outbound[h2_client_preface.len + n1..])! |
| 90 | headers := f2 as H2HeadersFrame |
| 91 | assert headers.stream_id == 1 |
| 92 | assert headers.end_stream // GET with no body |
| 93 | mut dec := H2HpackDecoder{} |
| 94 | req_fields := dec.decode(headers.fragment)! |
| 95 | assert req_fields.any(it.name == ':method' && it.value == 'GET') |
| 96 | assert req_fields.any(it.name == ':scheme' && it.value == 'https') |
| 97 | assert req_fields.any(it.name == ':authority' && it.value == 'example.com') |
| 98 | assert req_fields.any(it.name == ':path' && it.value == '/') |
| 99 | } |
| 100 | |
| 101 | fn test_h2_conn_multi_data_frames() { |
| 102 | inbound := build_server_stream([H2HeaderField{':status', '200'}], [ |
| 103 | 'foo'.bytes(), |
| 104 | 'bar'.bytes(), |
| 105 | 'baz'.bytes(), |
| 106 | ]) |
| 107 | mut t := &MockTransport{ |
| 108 | inbound: inbound |
| 109 | } |
| 110 | mut c := new_h2_conn(t) |
| 111 | resp := c.do(H2ClientRequest{ authority: 'h.example' })! |
| 112 | assert resp.status == 200 |
| 113 | assert resp.body.bytestr() == 'foobarbaz' |
| 114 | } |
| 115 | |
| 116 | fn test_h2_conn_post_body() { |
| 117 | inbound := build_server_stream([H2HeaderField{':status', '201'}], []) |
| 118 | mut t := &MockTransport{ |
| 119 | inbound: inbound |
| 120 | } |
| 121 | mut c := new_h2_conn(t) |
| 122 | resp := c.do(H2ClientRequest{ |
| 123 | method: 'POST' |
| 124 | authority: 'h.example' |
| 125 | path: '/submit' |
| 126 | body: 'payload-data'.bytes() |
| 127 | })! |
| 128 | assert resp.status == 201 |
| 129 | |
| 130 | // Walk the client's frames and confirm a DATA frame carried the body with |
| 131 | // END_STREAM, after a HEADERS frame that did not end the stream. |
| 132 | mut pos := h2_client_preface.len |
| 133 | mut saw_headers_open := false |
| 134 | mut data_payload := []u8{} |
| 135 | mut data_end := false |
| 136 | for pos < t.outbound.len { |
| 137 | frame, n := h2_read_frame(t.outbound[pos..])! |
| 138 | pos += n |
| 139 | match frame { |
| 140 | H2HeadersFrame { |
| 141 | if frame.stream_id == 1 { |
| 142 | assert !frame.end_stream |
| 143 | saw_headers_open = true |
| 144 | } |
| 145 | } |
| 146 | H2DataFrame { |
| 147 | if frame.stream_id == 1 { |
| 148 | data_payload << frame.data |
| 149 | if frame.end_stream { |
| 150 | data_end = true |
| 151 | } |
| 152 | } |
| 153 | } |
| 154 | else {} |
| 155 | } |
| 156 | } |
| 157 | assert saw_headers_open |
| 158 | assert data_end |
| 159 | assert data_payload.bytestr() == 'payload-data' |
| 160 | } |
| 161 | |
| 162 | fn test_h2_conn_goaway_errors() { |
| 163 | mut inbound := []u8{} |
| 164 | inbound << H2Frame(H2SettingsFrame{}).encode() |
| 165 | inbound << H2Frame(H2GoawayFrame{ |
| 166 | last_stream_id: 0 |
| 167 | error_code: u32(H2ErrorCode.protocol_error) |
| 168 | }).encode() |
| 169 | mut t := &MockTransport{ |
| 170 | inbound: inbound |
| 171 | } |
| 172 | mut c := new_h2_conn(t) |
| 173 | c.do(H2ClientRequest{ authority: 'h.example' }) or { |
| 174 | assert err.msg().contains('GOAWAY') |
| 175 | assert err.msg().contains('PROTOCOL_ERROR') |
| 176 | return |
| 177 | } |
| 178 | assert false, 'expected GOAWAY error' |
| 179 | } |
| 180 | |
| 181 | fn test_h2_conn_rst_stream_errors() { |
| 182 | mut senc := H2HpackEncoder{} |
| 183 | mut inbound := []u8{} |
| 184 | inbound << H2Frame(H2SettingsFrame{}).encode() |
| 185 | inbound << H2Frame(H2HeadersFrame{ |
| 186 | stream_id: 1 |
| 187 | fragment: senc.encode([H2HeaderField{':status', '200'}]) |
| 188 | end_headers: true |
| 189 | }).encode() |
| 190 | inbound << H2Frame(H2RstStreamFrame{ |
| 191 | stream_id: 1 |
| 192 | error_code: u32(H2ErrorCode.internal_error) |
| 193 | }).encode() |
| 194 | mut t := &MockTransport{ |
| 195 | inbound: inbound |
| 196 | } |
| 197 | mut c := new_h2_conn(t) |
| 198 | c.do(H2ClientRequest{ authority: 'h.example' }) or { |
| 199 | assert err.msg().contains('reset') |
| 200 | return |
| 201 | } |
| 202 | assert false, 'expected RST_STREAM error' |
| 203 | } |
| 204 | |
| 205 | fn test_h2_conn_continuation() { |
| 206 | // Split the response header block across HEADERS + CONTINUATION. |
| 207 | mut senc := H2HpackEncoder{} |
| 208 | full := senc.encode([ |
| 209 | H2HeaderField{':status', '200'}, |
| 210 | H2HeaderField{'x-test', 'continued'}, |
| 211 | ]) |
| 212 | split := full.len / 2 |
| 213 | mut inbound := []u8{} |
| 214 | inbound << H2Frame(H2SettingsFrame{}).encode() |
| 215 | inbound << H2Frame(H2HeadersFrame{ |
| 216 | stream_id: 1 |
| 217 | fragment: full[..split] |
| 218 | end_headers: false |
| 219 | }).encode() |
| 220 | inbound << H2Frame(H2ContinuationFrame{ |
| 221 | stream_id: 1 |
| 222 | fragment: full[split..] |
| 223 | end_headers: true |
| 224 | }).encode() |
| 225 | inbound << H2Frame(H2DataFrame{ |
| 226 | stream_id: 1 |
| 227 | data: 'ok'.bytes() |
| 228 | end_stream: true |
| 229 | }).encode() |
| 230 | mut t := &MockTransport{ |
| 231 | inbound: inbound |
| 232 | } |
| 233 | mut c := new_h2_conn(t) |
| 234 | resp := c.do(H2ClientRequest{ authority: 'h.example' })! |
| 235 | assert resp.status == 200 |
| 236 | assert resp.body.bytestr() == 'ok' |
| 237 | assert resp.headers.any(it.name == 'x-test' && it.value == 'continued') |
| 238 | } |
| 239 | |
| 240 | fn test_h2_conn_ping_ack() { |
| 241 | // A server PING before the response must be answered with a PING ACK. |
| 242 | mut senc := H2HpackEncoder{} |
| 243 | mut inbound := []u8{} |
| 244 | inbound << H2Frame(H2SettingsFrame{}).encode() |
| 245 | inbound << H2Frame(H2PingFrame{ |
| 246 | data: [u8(1), 2, 3, 4, 5, 6, 7, 8] |
| 247 | }).encode() |
| 248 | inbound << H2Frame(H2HeadersFrame{ |
| 249 | stream_id: 1 |
| 250 | fragment: senc.encode([H2HeaderField{':status', '204'}]) |
| 251 | end_headers: true |
| 252 | end_stream: true |
| 253 | }).encode() |
| 254 | mut t := &MockTransport{ |
| 255 | inbound: inbound |
| 256 | } |
| 257 | mut c := new_h2_conn(t) |
| 258 | resp := c.do(H2ClientRequest{ authority: 'h.example' })! |
| 259 | assert resp.status == 204 |
| 260 | |
| 261 | // Find a PING ACK in the client's output carrying the same opaque data. |
| 262 | mut pos := h2_client_preface.len |
| 263 | mut saw_ping_ack := false |
| 264 | for pos < t.outbound.len { |
| 265 | frame, n := h2_read_frame(t.outbound[pos..])! |
| 266 | pos += n |
| 267 | if frame is H2PingFrame { |
| 268 | if frame.ack && frame.data == [u8(1), 2, 3, 4, 5, 6, 7, 8] { |
| 269 | saw_ping_ack = true |
| 270 | } |
| 271 | } |
| 272 | } |
| 273 | assert saw_ping_ack |
| 274 | } |
| 275 | |
| 276 | fn test_h2_conn_splits_large_request_headers() { |
| 277 | inbound := build_server_stream([H2HeaderField{':status', '200'}], []) |
| 278 | mut t := &MockTransport{ |
| 279 | inbound: inbound |
| 280 | } |
| 281 | mut c := new_h2_conn(t) |
| 282 | // A header value larger than the default 16 KiB max frame size forces the |
| 283 | // request header block to be split across HEADERS + CONTINUATION frames. |
| 284 | big := 'x'.repeat(20000) |
| 285 | resp := c.do(H2ClientRequest{ |
| 286 | authority: 'h.example' |
| 287 | headers: [H2HeaderField{'x-big', big}] |
| 288 | })! |
| 289 | assert resp.status == 200 |
| 290 | |
| 291 | mut pos := h2_client_preface.len |
| 292 | mut headers_open := false |
| 293 | mut continuation_end := false |
| 294 | mut block := []u8{} |
| 295 | for pos < t.outbound.len { |
| 296 | frame, n := h2_read_frame(t.outbound[pos..])! |
| 297 | pos += n |
| 298 | match frame { |
| 299 | H2HeadersFrame { |
| 300 | if frame.stream_id == 1 { |
| 301 | assert !frame.end_headers // split, so END_HEADERS not on HEADERS |
| 302 | headers_open = true |
| 303 | block << frame.fragment |
| 304 | } |
| 305 | } |
| 306 | H2ContinuationFrame { |
| 307 | if frame.stream_id == 1 { |
| 308 | block << frame.fragment |
| 309 | if frame.end_headers { |
| 310 | continuation_end = true |
| 311 | } |
| 312 | } |
| 313 | } |
| 314 | else {} |
| 315 | } |
| 316 | } |
| 317 | assert headers_open |
| 318 | assert continuation_end |
| 319 | // The reassembled block must decode back to the original header. |
| 320 | mut dec := H2HpackDecoder{} |
| 321 | fields := dec.decode(block)! |
| 322 | assert fields.any(it.name == 'x-big' && it.value == big) |
| 323 | } |
| 324 | |
| 325 | fn test_h2_conn_large_body_flow_control() { |
| 326 | // Body larger than the initial 64 KiB window: the client sends up to the |
| 327 | // window, waits, then resumes once the peer grows both the connection and |
| 328 | // stream windows. |
| 329 | body := 'a'.repeat(70000) |
| 330 | mut inbound := []u8{} |
| 331 | inbound << H2Frame(H2SettingsFrame{}).encode() |
| 332 | inbound << H2Frame(H2WindowUpdateFrame{ |
| 333 | stream_id: 0 |
| 334 | window_size_increment: 70000 |
| 335 | }).encode() |
| 336 | inbound << H2Frame(H2WindowUpdateFrame{ |
| 337 | stream_id: 1 |
| 338 | window_size_increment: 70000 |
| 339 | }).encode() |
| 340 | mut senc := H2HpackEncoder{} |
| 341 | inbound << H2Frame(H2HeadersFrame{ |
| 342 | stream_id: 1 |
| 343 | fragment: senc.encode([H2HeaderField{':status', '200'}]) |
| 344 | end_headers: true |
| 345 | end_stream: true |
| 346 | }).encode() |
| 347 | mut t := &MockTransport{ |
| 348 | inbound: inbound |
| 349 | } |
| 350 | mut c := new_h2_conn(t) |
| 351 | resp := c.do(H2ClientRequest{ |
| 352 | method: 'POST' |
| 353 | authority: 'h.example' |
| 354 | path: '/upload' |
| 355 | body: body.bytes() |
| 356 | })! |
| 357 | assert resp.status == 200 |
| 358 | |
| 359 | // The client must have sent the entire body, ending with END_STREAM, and no |
| 360 | // single DATA frame may exceed the max frame size. |
| 361 | mut pos := h2_client_preface.len |
| 362 | mut sent := 0 |
| 363 | mut ended := false |
| 364 | for pos < t.outbound.len { |
| 365 | frame, n := h2_read_frame(t.outbound[pos..])! |
| 366 | pos += n |
| 367 | if frame is H2DataFrame { |
| 368 | if frame.stream_id == 1 { |
| 369 | assert frame.data.len <= int(h2_default_max_frame_size) |
| 370 | sent += frame.data.len |
| 371 | if frame.end_stream { |
| 372 | ended = true |
| 373 | } |
| 374 | } |
| 375 | } |
| 376 | } |
| 377 | assert sent == body.len |
| 378 | assert ended |
| 379 | } |
| 380 | |
| 381 | fn test_h2_fetch_glue_roundtrip() { |
| 382 | // Drive the full conversion glue (Request -> H2 -> Response) over the mock |
| 383 | // transport, without a socket. |
| 384 | inbound := build_server_stream([ |
| 385 | H2HeaderField{':status', '200'}, |
| 386 | H2HeaderField{'content-type', 'text/plain'}, |
| 387 | ], [' world'.bytes()]) |
| 388 | mut t := &MockTransport{ |
| 389 | inbound: inbound |
| 390 | } |
| 391 | req := Request{ |
| 392 | user_agent: 'v.http' |
| 393 | } |
| 394 | h2req := req.to_h2_request(.get, 'example.com', '/', '', new_header()) |
| 395 | mut c := new_h2_conn(t) |
| 396 | h2resp := c.do(h2req)! |
| 397 | resp := h2_response_to_http(h2resp) |
| 398 | assert resp.status_code == 200 |
| 399 | assert resp.version() == .v2_0 |
| 400 | assert resp.body == ' world' |
| 401 | assert (resp.header.get_custom('content-type') or { '' }) == 'text/plain' |
| 402 | } |
| 403 | |
| 404 | // build_streamed_response builds a server stream that delivers the response |
| 405 | // body in fixed-size chunks, useful for streaming tests. |
| 406 | fn build_streamed_response(status string, content_length string, chunks [][]u8) []u8 { |
| 407 | mut senc := H2HpackEncoder{} |
| 408 | mut fields := [H2HeaderField{':status', status}] |
| 409 | if content_length != '' { |
| 410 | fields << H2HeaderField{'content-length', content_length} |
| 411 | } |
| 412 | hdr := senc.encode(fields) |
| 413 | mut out := []u8{} |
| 414 | out << H2Frame(H2SettingsFrame{}).encode() |
| 415 | out << H2Frame(H2SettingsFrame{ |
| 416 | ack: true |
| 417 | }).encode() |
| 418 | last_is_headers := chunks.len == 0 |
| 419 | out << H2Frame(H2HeadersFrame{ |
| 420 | stream_id: 1 |
| 421 | fragment: hdr |
| 422 | end_headers: true |
| 423 | end_stream: last_is_headers |
| 424 | }).encode() |
| 425 | for i, chunk in chunks { |
| 426 | out << H2Frame(H2DataFrame{ |
| 427 | stream_id: 1 |
| 428 | data: chunk |
| 429 | end_stream: i == chunks.len - 1 |
| 430 | }).encode() |
| 431 | } |
| 432 | return out |
| 433 | } |
| 434 | |
| 435 | // ChunkCapture records on_data invocations via a reference captured by the |
| 436 | // returned closure. |
| 437 | struct ChunkCapture { |
| 438 | mut: |
| 439 | chunks [][]u8 |
| 440 | running []u64 |
| 441 | expected []u64 |
| 442 | status []int |
| 443 | } |
| 444 | |
| 445 | fn make_capture_fn(cap &ChunkCapture) H2DataFn { |
| 446 | return fn [cap] (chunk []u8, body_so_far u64, body_expected u64, status int) ! { |
| 447 | unsafe { |
| 448 | cap.chunks << chunk.clone() |
| 449 | cap.running << body_so_far |
| 450 | cap.expected << body_expected |
| 451 | cap.status << status |
| 452 | } |
| 453 | } |
| 454 | } |
| 455 | |
| 456 | fn test_h2_on_data_fires_per_chunk() { |
| 457 | mut cap := &ChunkCapture{} |
| 458 | inbound := build_streamed_response('200', '12', [ |
| 459 | 'foo'.bytes(), |
| 460 | 'bar'.bytes(), |
| 461 | 'baz quux'.bytes(), |
| 462 | ]) |
| 463 | mut t := &MockTransport{ |
| 464 | inbound: inbound |
| 465 | } |
| 466 | mut c := new_h2_conn(t) |
| 467 | resp := c.do(H2ClientRequest{ |
| 468 | authority: 'h.example' |
| 469 | on_data: make_capture_fn(cap) |
| 470 | })! |
| 471 | assert resp.status == 200 |
| 472 | assert resp.body.bytestr() == 'foobarbaz quux' |
| 473 | // Three DATA frames -> three callback invocations. |
| 474 | assert cap.chunks.len == 3 |
| 475 | assert cap.chunks[0].bytestr() == 'foo' |
| 476 | assert cap.chunks[1].bytestr() == 'bar' |
| 477 | assert cap.chunks[2].bytestr() == 'baz quux' |
| 478 | // body_so_far is cumulative including the current chunk. |
| 479 | assert cap.running == [u64(3), u64(6), u64(14)] |
| 480 | // content-length is reported. |
| 481 | assert cap.expected == [u64(12), u64(12), u64(12)] |
| 482 | // Status was known by the first callback (headers arrived first). |
| 483 | assert cap.status == [200, 200, 200] |
| 484 | } |
| 485 | |
| 486 | fn test_h2_stop_copying_limit_caps_body_but_keeps_callback() { |
| 487 | mut cap := &ChunkCapture{} |
| 488 | inbound := build_streamed_response('200', '', [ |
| 489 | 'AAAA'.bytes(), |
| 490 | 'BBBB'.bytes(), |
| 491 | 'CCCC'.bytes(), |
| 492 | ]) |
| 493 | mut t := &MockTransport{ |
| 494 | inbound: inbound |
| 495 | } |
| 496 | mut c := new_h2_conn(t) |
| 497 | resp := c.do(H2ClientRequest{ |
| 498 | authority: 'h.example' |
| 499 | on_data: make_capture_fn(cap) |
| 500 | stop_copying_limit: 6 |
| 501 | })! |
| 502 | // Body is capped at 6 bytes (4 from first chunk + 2 from second). |
| 503 | assert resp.body.bytestr() == 'AAAABB' |
| 504 | // All three chunks still produced callbacks. |
| 505 | assert cap.chunks.len == 3 |
| 506 | assert cap.chunks[0].bytestr() == 'AAAA' |
| 507 | assert cap.chunks[1].bytestr() == 'BBBB' |
| 508 | assert cap.chunks[2].bytestr() == 'CCCC' |
| 509 | // body_so_far still reports the true cumulative size. |
| 510 | assert cap.running == [u64(4), u64(8), u64(12)] |
| 511 | } |
| 512 | |
| 513 | fn test_h2_stop_receiving_limit_breaks_early() { |
| 514 | mut cap := &ChunkCapture{} |
| 515 | inbound := build_streamed_response('200', '', [ |
| 516 | 'XXXX'.bytes(), |
| 517 | 'YYYY'.bytes(), |
| 518 | 'ZZZZ'.bytes(), // should never be delivered |
| 519 | ]) |
| 520 | mut t := &MockTransport{ |
| 521 | inbound: inbound |
| 522 | } |
| 523 | mut c := new_h2_conn(t) |
| 524 | resp := c.do(H2ClientRequest{ |
| 525 | authority: 'h.example' |
| 526 | on_data: make_capture_fn(cap) |
| 527 | stop_receiving_limit: 6 |
| 528 | })! |
| 529 | // Loop breaks after the second DATA frame (8 bytes >= limit 6); body |
| 530 | // contains both chunks delivered so far. |
| 531 | assert resp.body.bytestr() == 'XXXXYYYY' |
| 532 | assert cap.chunks.len == 2 |
| 533 | assert cap.chunks[1].bytestr() == 'YYYY' |
| 534 | |
| 535 | // On early termination the client must send RST_STREAM(CANCEL) on the |
| 536 | // stream, and the connection must refuse further requests. |
| 537 | mut saw_cancel := false |
| 538 | mut pos := h2_client_preface.len |
| 539 | for pos < t.outbound.len { |
| 540 | frame, n := h2_read_frame(t.outbound[pos..])! |
| 541 | pos += n |
| 542 | if frame is H2RstStreamFrame { |
| 543 | if frame.stream_id == 1 && frame.error_code == u32(H2ErrorCode.cancel) { |
| 544 | saw_cancel = true |
| 545 | } |
| 546 | } |
| 547 | } |
| 548 | assert saw_cancel, 'expected RST_STREAM(CANCEL) on early termination' |
| 549 | c.do(H2ClientRequest{ authority: 'h.example' }) or { |
| 550 | assert err.msg().contains('no longer usable') |
| 551 | return |
| 552 | } |
| 553 | assert false, 'expected error on reuse after early termination' |
| 554 | } |
| 555 | |