|
| 1 | +using Test |
| 2 | +using HTTP |
| 3 | +using Reseau |
| 4 | + |
| 5 | +const HT = HTTP |
| 6 | +const ND = Reseau.HostResolvers |
| 7 | +const NC = Reseau.TCP |
| 8 | + |
| 9 | +# RFC 9113 §7 error codes |
| 10 | +const _RSR_REFUSED_STREAM = UInt32(0x7) |
| 11 | +const _RSR_NO_ERROR = UInt32(0x0) |
| 12 | + |
| 13 | +function _rsr_write_frame!(conn::NC.Conn, frame::HT.AbstractFrame) |
| 14 | + io = IOBuffer() |
| 15 | + HT.write_frame!(io, frame) |
| 16 | + bytes = take!(io) |
| 17 | + total = 0 |
| 18 | + while total < length(bytes) |
| 19 | + n = write(conn, bytes[(total + 1):end]) |
| 20 | + n > 0 || error("expected write progress") |
| 21 | + total += n |
| 22 | + end |
| 23 | + return nothing |
| 24 | +end |
| 25 | + |
| 26 | +function _rsr_read_preface!(conn::NC.Conn) |
| 27 | + n = length(HT._H2_PREFACE) |
| 28 | + offset = 0 |
| 29 | + while offset < n |
| 30 | + chunk = Vector{UInt8}(undef, n - offset) |
| 31 | + nr = readbytes!(conn, chunk) |
| 32 | + nr > 0 || error("unexpected EOF reading client preface") |
| 33 | + offset += nr |
| 34 | + end |
| 35 | + return nothing |
| 36 | +end |
| 37 | + |
| 38 | +function _rsr_next_headers!(reader)::HT.HeadersFrame |
| 39 | + while true |
| 40 | + frame = HT.read_frame!(reader) |
| 41 | + frame isa HT.HeadersFrame && return frame::HT.HeadersFrame |
| 42 | + frame isa HT.WindowUpdateFrame && continue |
| 43 | + frame isa HT.SettingsFrame && continue |
| 44 | + frame isa HT.PingFrame && continue |
| 45 | + error("expected headers frame, got $(typeof(frame))") |
| 46 | + end |
| 47 | +end |
| 48 | + |
| 49 | +# Scripted h2 server: refuses the first `refuse_first` requests (RST_STREAM |
| 50 | +# REFUSED_STREAM, or GOAWAY rejecting the in-flight stream), then answers 200. |
| 51 | +# An accept loop plus a per-connection headers loop keeps it agnostic to |
| 52 | +# whether the client retries on the same connection or on a new one. |
| 53 | +function _rsr_serve!(listener; scenario::Symbol, refuse_first::Int = 1) |
| 54 | + attempts = Threads.Atomic{Int}(0) |
| 55 | + conns = Threads.Atomic{Int}(0) |
| 56 | + accept_task = errormonitor(Threads.@spawn begin |
| 57 | + while true |
| 58 | + conn = try |
| 59 | + NC.accept(listener) |
| 60 | + catch |
| 61 | + break # listener closed: end of test |
| 62 | + end |
| 63 | + Threads.atomic_add!(conns, 1) |
| 64 | + errormonitor(Threads.@spawn begin |
| 65 | + try |
| 66 | + _rsr_read_preface!(conn) |
| 67 | + _rsr_write_frame!(conn, HT.SettingsFrame(false, Pair{UInt16, UInt32}[])) |
| 68 | + reader = HT._ConnReader(conn) |
| 69 | + encoder = HT.Encoder() |
| 70 | + while true |
| 71 | + hf = _rsr_next_headers!(reader) |
| 72 | + attempt = Threads.atomic_add!(attempts, 1) + 1 |
| 73 | + if attempt <= refuse_first |
| 74 | + if scenario === :rst |
| 75 | + _rsr_write_frame!(conn, HT.RSTStreamFrame(hf.stream_id, _RSR_REFUSED_STREAM)) |
| 76 | + elseif scenario === :goaway |
| 77 | + _rsr_write_frame!(conn, HT.GoAwayFrame(UInt32(0), _RSR_NO_ERROR, UInt8[])) |
| 78 | + break |
| 79 | + else |
| 80 | + error("unknown scenario $scenario") |
| 81 | + end |
| 82 | + else |
| 83 | + encoded = HT.encode_header_block(encoder, HT.HeaderField[HT.HeaderField(":status", "200", false)]) |
| 84 | + _rsr_write_frame!(conn, HT.HeadersFrame(hf.stream_id, true, true, encoded)) |
| 85 | + end |
| 86 | + end |
| 87 | + catch |
| 88 | + # client tore the connection down (expected on error paths) |
| 89 | + finally |
| 90 | + HTTP.@try_ignore NC.close(conn) |
| 91 | + end |
| 92 | + end) |
| 93 | + end |
| 94 | + end) |
| 95 | + return attempts, conns, accept_task |
| 96 | +end |
| 97 | + |
| 98 | +function _rsr_request(url) |
| 99 | + return try |
| 100 | + HT.get(url; protocol = :h2, retry = true, connect_timeout = 5, request_timeout = 15) |
| 101 | + catch err |
| 102 | + err |
| 103 | + end |
| 104 | +end |
| 105 | + |
| 106 | +@testset "HTTP/2 retry of guaranteed-unprocessed streams (RFC 9113 §8.7)" begin |
| 107 | + |
| 108 | +# A stream reset with REFUSED_STREAM is guaranteed unprocessed (RFC 9113 §8.7) |
| 109 | +# and must be retried transparently. |
| 110 | +@testset "HTTP/2 client retries a stream reset with REFUSED_STREAM" begin |
| 111 | + listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 8) |
| 112 | + laddr = NC.addr(listener)::NC.SocketAddrV4 |
| 113 | + url = "http://" * ND.join_host_port("127.0.0.1", Int(laddr.port)) * "/" |
| 114 | + attempts, conns, accept_task = _rsr_serve!(listener; scenario = :rst) |
| 115 | + try |
| 116 | + result = _rsr_request(url) |
| 117 | + @info "REFUSED_STREAM scenario" result attempts[] conns[] |
| 118 | + @test attempts[] == 2 # the refused attempt was retried |
| 119 | + @test result isa HT.Response |
| 120 | + result isa HT.Response && @test (result::HT.Response).status == 200 |
| 121 | + finally |
| 122 | + HTTP.@try_ignore NC.close(listener) |
| 123 | + HTTP.@try_ignore wait(accept_task) |
| 124 | + end |
| 125 | +end |
| 126 | + |
| 127 | +# A stream rejected by GOAWAY (stream_id > last_stream_id) is likewise |
| 128 | +# unprocessed and must be retried, necessarily on a new connection. |
| 129 | +@testset "HTTP/2 client retries a stream rejected by GOAWAY" begin |
| 130 | + listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 8) |
| 131 | + laddr = NC.addr(listener)::NC.SocketAddrV4 |
| 132 | + url = "http://" * ND.join_host_port("127.0.0.1", Int(laddr.port)) * "/" |
| 133 | + attempts, conns, accept_task = _rsr_serve!(listener; scenario = :goaway) |
| 134 | + try |
| 135 | + result = _rsr_request(url) |
| 136 | + @info "GOAWAY scenario" result attempts[] conns[] |
| 137 | + @test attempts[] == 2 |
| 138 | + @test conns[] == 2 # GOAWAY drains the first connection |
| 139 | + @test result isa HT.Response |
| 140 | + result isa HT.Response && @test (result::HT.Response).status == 200 |
| 141 | + finally |
| 142 | + HTTP.@try_ignore NC.close(listener) |
| 143 | + HTTP.@try_ignore wait(accept_task) |
| 144 | + end |
| 145 | +end |
| 146 | + |
| 147 | +end # parent testset |
0 commit comments