diff --git a/src/http2_client.jl b/src/http2_client.jl index 9ee4f2a6c..dd725156f 100644 --- a/src/http2_client.jl +++ b/src/http2_client.jl @@ -310,16 +310,22 @@ end end end -function _wait_h2_send_window_locked!(conn::H2Connection, stream_id::UInt32, deadline_ns::Int64)::Nothing +function _wait_h2_send_window_locked!( + conn::H2Connection, + stream_id::UInt32, + deadline_ns::Int64; + clock_ns::Function=time_ns, + wait_for::Function=IOPoll.timedwait, +)::Nothing if deadline_ns == 0 wait(conn.window_condition) return nothing end - remaining_ns = deadline_ns - Int64(time_ns()) + remaining_ns = deadline_ns - Int64(clock_ns()) remaining_ns <= 0 && throw(IOPoll.DeadlineExceededError()) unlock(conn.state_lock) try - status = IOPoll.timedwait(() -> begin + status = wait_for(() -> begin lock(conn.state_lock) try return _h2_send_window_ready_locked(conn, stream_id) diff --git a/src/http2_server.jl b/src/http2_server.jl index e5e54250d..4288144f6 100644 --- a/src/http2_server.jl +++ b/src/http2_server.jl @@ -310,16 +310,21 @@ function _apply_h2_window_update!(send_state::_H2SendWindowState, frame::WindowU return nothing end -function _wait_h2_send_window_locked!(send_state::_H2SendWindowState, deadline_ns::Int64)::Nothing +function _wait_h2_send_window_locked!( + send_state::_H2SendWindowState, + deadline_ns::Int64; + clock_ns::Function=time_ns, + wait_ns::Function=IOPoll.sleep_ns, +)::Nothing if deadline_ns == 0 wait(send_state.window_condition) return nothing end - remaining_ns = deadline_ns - Int64(time_ns()) + remaining_ns = deadline_ns - Int64(clock_ns()) remaining_ns <= 0 && throw(IOPoll.DeadlineExceededError()) unlock(send_state.state_lock) try - IOPoll.sleep_ns(min(remaining_ns, Int64(1_000_000))) + wait_ns(min(remaining_ns, Int64(1_000_000))) finally lock(send_state.state_lock) end diff --git a/src/http_client_timeouts.jl b/src/http_client_timeouts.jl index 9152051e9..410315e03 100644 --- a/src/http_client_timeouts.jl +++ b/src/http_client_timeouts.jl @@ -77,10 +77,11 @@ function _apply_request_timeout_settings!( ctx::RequestContext, request_timeout_ns::Int64, config::Union{Nothing,_RequestTimeoutConfig}, + ; + now_ns::Int64=Int64(time_ns()), )::RequestContext request_timeout_ns < 0 && throw(ArgumentError("request_timeout_ns must be >= 0")) if request_timeout_ns > 0 - now_ns = Int64(time_ns()) deadline_ns = now_ns > typemax(Int64) - request_timeout_ns ? typemax(Int64) : now_ns + request_timeout_ns set_deadline!(ctx, deadline_ns) end diff --git a/src/http_retry.jl b/src/http_retry.jl index 5537d293b..28c36b10a 100644 --- a/src/http_retry.jl +++ b/src/http_retry.jl @@ -174,7 +174,10 @@ function _retry_after_delay_ns(headers::Headers)::Union{Nothing,Int64} return _parse_retry_after_delay_ns(value::String) end -function _parse_retry_after_delay_ns(value::AbstractString)::Union{Nothing,Int64} +function _parse_retry_after_delay_ns( + value::AbstractString; + now::Dates.DateTime=Dates.now(Dates.UTC), +)::Union{Nothing,Int64} stripped = strip(String(value)) isempty(stripped) && return nothing parsed_secs = try @@ -190,7 +193,7 @@ function _parse_retry_after_delay_ns(value::AbstractString)::Union{Nothing,Int64 end parsed_dt = Cookies._parse_http_gmt_datetime(stripped) parsed_dt === nothing && return nothing - delta = parsed_dt::Dates.DateTime - Dates.now(Dates.UTC) + delta = parsed_dt::Dates.DateTime - now millis = Dates.value(delta) millis <= 0 && return Int64(0) millis > typemax(Int64) ÷ 1_000_000 && return typemax(Int64) diff --git a/src/http_transport.jl b/src/http_transport.jl index 660bd9748..6fbca02ce 100644 --- a/src/http_transport.jl +++ b/src/http_transport.jl @@ -217,6 +217,7 @@ mutable struct Transport max_conns_per_host::Int idle_timeout_ns::Int64 lock::ReentrantLock + waiter_condition::Threads.Condition idle::Dict{String,Vector{Conn}} waiters::Dict{String,Vector{_ConnWaiter}} conns_per_host::Dict{String,Int} @@ -280,6 +281,7 @@ function Transport(; max_conns_per_host >= 0 || throw(ArgumentError("max_conns_per_host must be >= 0")) idle_timeout_ns >= 0 || throw(ArgumentError("idle_timeout_ns must be >= 0")) host_resolver = HostResolvers.HostResolver(local_addr=_normalize_local_addr(local_addr)) + lock = ReentrantLock() return Transport( host_resolver, tls_config, @@ -289,7 +291,8 @@ function Transport(; Int(max_idle_total), Int(max_conns_per_host), Int64(idle_timeout_ns), - ReentrantLock(), + lock, + Threads.Condition(lock), Dict{String,Vector{Conn}}(), Dict{String,Vector{_ConnWaiter}}(), Dict{String,Int}(), @@ -541,6 +544,7 @@ function _enqueue_waiter_locked!(transport::Transport, waiter::_ConnWaiter) queue = get(() -> _ConnWaiter[], transport.waiters, waiter.key) push!(queue, waiter) transport.waiters[waiter.key] = queue + notify(transport.waiter_condition; all=true) return waiter end @@ -643,7 +647,13 @@ function _deliver_waiter_error_locked!(waiter::_ConnWaiter, err::Exception)::Boo return true end -function _wait_for_conn!(transport::Transport, waiter::_ConnWaiter, deadline_ns::Int64) +function _wait_for_conn!( + transport::Transport, + waiter::_ConnWaiter, + deadline_ns::Int64; + clock_ns::Function=time_ns, + wait_for::Function=IOPoll.timedwait, +) while true state = @atomic :acquire waiter.state if state == _CONN_WAITER_CONN @@ -659,7 +669,7 @@ function _wait_for_conn!(transport::Transport, waiter::_ConnWaiter, deadline_ns: wait(waiter.signal) continue end - now_ns = Int64(time_ns()) + now_ns = Int64(clock_ns()) if now_ns >= deadline_ns lock(transport.lock) try @@ -674,7 +684,7 @@ function _wait_for_conn!(transport::Transport, waiter::_ConnWaiter, deadline_ns: continue end timeout_s = min((deadline_ns - now_ns) / 1.0e9, 0.05) - IOPoll.timedwait(() -> (@atomic :acquire waiter.state) != _CONN_WAITER_WAITING, timeout_s; pollint=0.001) + wait_for(() -> (@atomic :acquire waiter.state) != _CONN_WAITER_WAITING, timeout_s; pollint=0.001) end end diff --git a/test/README.md b/test/README.md new file mode 100644 index 000000000..7c84b47b0 --- /dev/null +++ b/test/README.md @@ -0,0 +1,26 @@ +# Deterministic test synchronization + +HTTP.jl tests must not depend on scheduler speed or elapsed wall-clock time. +GitHub Actions runners can pause a task for an unknown period. A delay that is +safe on one runner can fail on another runner without a product defect. + +Use observable state transitions instead: + +- Use a `Channel`, `Base.Event`, or `Threads.Condition` for task handshakes. +- Read exact byte counts, complete protocol frames, markers, or EOF. +- Use `fetch(task)` or `wait(task)` for task completion. Wrap unexpected + `Threads.@spawn` failures with `errormonitor`. +- Inject a fixed clock value into pure deadline calculations. +- Use an already-expired absolute deadline when a test must enter a product + timeout branch. Do not wait for a future deadline to expire. +- Mutate private lifecycle state only when the test directly covers that state, + such as an idle-pool eviction test. + +Do not use `sleep`, `timedwait`, `time`, `time_ns`, `Timer`, elapsed-time +assertions, polling intervals, or helper-level timeout arguments in test code. +Do not use a short delay to prove that an event has not occurred. Build a +barrier that makes the event impossible until the test releases it. + +Product timeout configuration remains valid test input. It tests parsing, +propagation, and expired-deadline behavior. It must not act as the test harness. +The GitHub Actions job timeout remains the final guard for a true deadlock. diff --git a/test/http1_wire_tests.jl b/test/http1_wire_tests.jl index 6afa1187f..56623b243 100644 --- a/test/http1_wire_tests.jl +++ b/test/http1_wire_tests.jl @@ -267,7 +267,7 @@ end # _ConnReader pulls it into one buffer fill -> later lines are served # from the buffered fast path. write(client, bytes) - Reseau.IOPoll.timedwait(() -> server_conn[] !== nothing, 5.0; pollint = 0.001) + fetch(t) return HT._ConnReader(server_conn[]::Reseau.TCP.Conn), client, listener catch HT.@try_ignore close(listener) @@ -326,7 +326,7 @@ end t = Task(() -> (server_conn[] = Reseau.TCP.accept(listener))) schedule(t) client = Reseau.TCP.connect(Reseau.TCP.loopback_addr(Int(addr.port))) - Reseau.IOPoll.timedwait(() -> server_conn[] !== nothing, 5.0; pollint = 0.001) + fetch(t) for c in chunks write(client, c) end diff --git a/test/http2_client_tests.jl b/test/http2_client_tests.jl index 93e21d6ba..3a9607012 100644 --- a/test/http2_client_tests.jl +++ b/test/http2_client_tests.jl @@ -67,9 +67,7 @@ function _write_padded_data_frame_to_conn!(conn::NC.Conn, stream_id::UInt32, dat return nothing end -function _wait_task_h2!(task::Task; timeout_s::Float64 = 5.0) - status = timedwait(() -> istaskdone(task), timeout_s; pollint = 0.001) - status == :timed_out && error("timed out waiting for h2 server task") +function _wait_task_h2!(task::Task) fetch(task) return nothing end @@ -305,17 +303,6 @@ end request = HT.Request("GET", "/partial-reset"; host = address, body = HT.EmptyBody(), content_length = 0) response = HT.h2_roundtrip!(h2_conn, request) put!(allow_reset, nothing) - state = (response.body::HT.H2Body).state - reset_received = timedwait(() -> begin - lock(state.lock) - try - return state.stream_error !== nothing - finally - unlock(state.lock) - end - end, 5.0; pollint = 0.001) - @test reset_received != :timed_out - buf = Vector{UInt8}(undef, length(payload)) @test HT.body_read!(response.body, buf) == length(payload) @test String(copy(buf)) == payload @@ -381,20 +368,7 @@ end try request = HT.Request("GET", "/complete-late-reset"; host = address, body = HT.EmptyBody(), content_length = 0) response = HT.h2_roundtrip!(h2_conn, request) - reset_seen = timedwait(() -> isready(reset_processed), 5.0; pollint = 0.001) - @test reset_seen != :timed_out - reset_seen == :timed_out && error("client did not process the late reset") take!(reset_processed) - state = (response.body::HT.H2Body).state - response_complete = timedwait(() -> begin - lock(state.lock) - try - return state.stream_done - finally - unlock(state.lock) - end - end, 5.0; pollint = 0.001) - @test response_complete != :timed_out @test String(_read_all_h2_body(response.body)) == "complete" @test HT.body_closed(response.body) @test HT._stream_state(h2_conn, (response.body::HT.H2Body).stream_id) === nothing @@ -447,17 +421,6 @@ end incomplete_response = HT.h2_roundtrip!(h2_conn, incomplete_request) put!(allow_close, nothing) _wait_task_h2!(server_task) - complete_state = (complete_response.body::HT.H2Body).state - incomplete_state = (incomplete_response.body::HT.H2Body).state - failure_seen = timedwait(() -> begin - complete_failed = lock(complete_state.lock) do - complete_state.conn_errored - end - return lock(incomplete_state.lock) do - complete_failed && incomplete_state.conn_errored - end - end, 5.0; pollint = 0.001) - @test failure_seen != :timed_out @test String(_read_all_h2_body(complete_response.body)) == "complete" @test_throws HT.ProtocolError HT.body_read!(incomplete_response.body, Vector{UInt8}(undef, 1)) HT.body_close!(incomplete_response.body) @@ -501,18 +464,8 @@ end listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 8) laddr = NC.addr(listener)::NC.SocketAddrV4 address = ND.join_host_port("127.0.0.1", Int(laddr.port)) - server_task = errormonitor(Threads.@spawn begin - accepted_conn = NC.accept(listener) - try - sleep(0.20) - finally - HTTP.@try_ignore NC.close(accepted_conn) - end - return nothing - end) try - @test_throws Reseau.IOPoll.DeadlineExceededError HT.connect_h2!(address; secure = false, connect_deadline_ns = Int64(time_ns() + 50_000_000)) - _wait_task_h2!(server_task) + @test_throws Reseau.IOPoll.DeadlineExceededError HT.connect_h2!(address; secure=false, connect_deadline_ns=1) finally HTTP.@try_ignore NC.close(listener) end @@ -546,6 +499,7 @@ end listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 8) laddr = NC.addr(listener)::NC.SocketAddrV4 address = ND.join_host_port("127.0.0.1", Int(laddr.port)) + release_server = Channel{Nothing}(1) server_task = errormonitor(Threads.@spawn begin accepted_conn = NC.accept(listener) reader = HT._ConnReader(accepted_conn) @@ -559,9 +513,7 @@ end headers_frame = _read_next_headers_frame!(reader) hf = headers_frame::HT.HeadersFrame _ = HT.decode_header_block(server_decoder, hf.header_block_fragment) - sleep(0.20) - encoded = HT.encode_header_block(server_encoder, HT.HeaderField[HT.HeaderField(":status", "200", false)]) - _write_frame_to_conn!(accepted_conn, HT.HeadersFrame(hf.stream_id, true, true, encoded)) + take!(release_server) finally HTTP.@try_ignore NC.close(accepted_conn) end @@ -570,13 +522,16 @@ end h2_conn = HT.connect_h2!(address; secure = false) try request = HT.Request("GET", "/slow-headers"; host = address, body = HT.EmptyBody(), content_length = 0) - request_timeout_ns, timeout_config = HT._resolve_request_timeout_settings(0, 0, 0.05) + request_timeout_ns, timeout_config = HT._resolve_request_timeout_settings(0, 0, 1.0e-9) HT._apply_request_timeout_settings!(HT.get_request_context(request), request_timeout_ns, timeout_config) @test_throws Reseau.IOPoll.DeadlineExceededError HT.h2_roundtrip!(h2_conn, request) + put!(release_server, nothing) _wait_task_h2!(server_task) finally + isready(release_server) || HTTP.@try_ignore put!(release_server, nothing) close(h2_conn) HTTP.@try_ignore NC.close(listener) + HTTP.@try_ignore fetch(server_task) end end @@ -584,6 +539,8 @@ end listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 8) laddr = NC.addr(listener)::NC.SocketAddrV4 address = ND.join_host_port("127.0.0.1", Int(laddr.port)) + partial_body_sent = Channel{Nothing}(1) + release_server = Channel{Nothing}(1) server_task = errormonitor(Threads.@spawn begin accepted_conn = NC.accept(listener) reader = HT._ConnReader(accepted_conn) @@ -600,7 +557,8 @@ end encoded = HT.encode_header_block(server_encoder, HT.HeaderField[HT.HeaderField(":status", "200", false)]) _write_frame_to_conn!(accepted_conn, HT.HeadersFrame(hf.stream_id, false, true, encoded)) _write_frame_to_conn!(accepted_conn, HT.DataFrame(hf.stream_id, false, UInt8[UInt8('a')])) - sleep(0.20) + put!(partial_body_sent, nothing) + take!(release_server) finally HTTP.@try_ignore NC.close(accepted_conn) end @@ -610,18 +568,24 @@ end response = nothing try request = HT.Request("GET", "/slow-body"; host = address, body = HT.EmptyBody(), content_length = 0) - request_timeout_ns, timeout_config = HT._resolve_request_timeout_settings(0, 0, 0, 0.05) - HT._apply_request_timeout_settings!(HT.get_request_context(request), request_timeout_ns, timeout_config) + ctx = HT.get_request_context(request) + request_timeout_ns, timeout_config = HT._resolve_request_timeout_settings(0, 0, 0, 60) + HT._apply_request_timeout_settings!(ctx, request_timeout_ns, timeout_config) response = HT.h2_roundtrip!(h2_conn, request) buf = Vector{UInt8}(undef, 8) @test HT.body_read!(response.body, buf) == 1 @test buf[1] == UInt8('a') + take!(partial_body_sent) + HT.set_deadline!(ctx, 1) @test_throws Reseau.IOPoll.DeadlineExceededError HT.body_read!(response.body, buf) + put!(release_server, nothing) _wait_task_h2!(server_task) finally + isready(release_server) || HTTP.@try_ignore put!(release_server, nothing) response === nothing || HTTP.@try_ignore HT.body_close!(response.body) close(h2_conn) HTTP.@try_ignore NC.close(listener) + HTTP.@try_ignore fetch(server_task) end end @@ -629,6 +593,7 @@ end listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 8) laddr = NC.addr(listener)::NC.SocketAddrV4 address = ND.join_host_port("127.0.0.1", Int(laddr.port)) + release_body = Channel{Nothing}(1) server_task = errormonitor(Threads.@spawn begin accepted_conn = NC.accept(listener) reader = HT._ConnReader(accepted_conn) @@ -644,7 +609,7 @@ end _ = HT.decode_header_block(server_decoder, hf.header_block_fragment) encoded = HT.encode_header_block(server_encoder, HT.HeaderField[HT.HeaderField(":status", "200", false)]) _write_frame_to_conn!(accepted_conn, HT.HeadersFrame(hf.stream_id, false, true, encoded)) - sleep(0.05) + take!(release_body) _write_frame_to_conn!(accepted_conn, HT.DataFrame(hf.stream_id, true, UInt8[UInt8('o'), UInt8('k')])) finally HTTP.@try_ignore NC.close(accepted_conn) @@ -655,12 +620,14 @@ end response = nothing try request = HT.Request("GET", "/delayed-body"; host = address, body = HT.EmptyBody(), content_length = 0) - request_timeout_ns, timeout_config = HT._resolve_request_timeout_settings(0, 0, 0, 0.5) + request_timeout_ns, timeout_config = HT._resolve_request_timeout_settings(0, 0, 0, 60) HT._apply_request_timeout_settings!(HT.get_request_context(request), request_timeout_ns, timeout_config) response = HT.h2_roundtrip!(h2_conn, request) + put!(release_body, nothing) @test String(_read_all_h2_body(response.body)) == "ok" _wait_task_h2!(server_task) finally + isready(release_body) || HTTP.@try_ignore put!(release_body, nothing) response === nothing || HTTP.@try_ignore HT.body_close!(response.body) close(h2_conn) HTTP.@try_ignore NC.close(listener) @@ -671,6 +638,8 @@ end listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 8) laddr = NC.addr(listener)::NC.SocketAddrV4 address = ND.join_host_port("127.0.0.1", Int(laddr.port)) + data_received = Channel{Nothing}(1) + release_server = Channel{Nothing}(1) server_task = errormonitor(Threads.@spawn begin accepted_conn = NC.accept(listener) reader = HT._ConnReader(accepted_conn) @@ -683,12 +652,10 @@ end headers_frame = _read_next_headers_frame!(reader) hf = headers_frame::HT.HeadersFrame _ = HT.decode_header_block(server_decoder, hf.header_block_fragment) - try - _ = HT.read_frame!(reader) - catch err - (err isa ParseError || err isa EOFError) || rethrow(err) - end - sleep(0.20) + data = HT.read_frame!(reader) + @test data isa HT.DataFrame + put!(data_received, nothing) + take!(release_server) finally HTTP.@try_ignore NC.close(accepted_conn) end @@ -696,15 +663,33 @@ end end) h2_conn = HT.connect_h2!(address; secure = false) try - body = HT.BytesBody(collect(codeunits("abcd"))) + ctx = HT.RequestContext() + read_count = Ref(0) + body = HT.CallbackBody(dst -> begin + read_count[] += 1 + if read_count[] == 1 + dst[1] = UInt8('a') + return 1 + elseif read_count[] == 2 + copyto!(dst, 1, collect(codeunits("bcd")), 1, 3) + return 3 + end + HT.set_deadline!(ctx, 1) + return 0 + end, () -> nothing) request = HT.Request("POST", "/slow-upload"; host = address, body = body, content_length = 4) - request_timeout_ns, timeout_config = HT._resolve_request_timeout_settings(0, 0, 0, 0, 0.05) - HT._apply_request_timeout_settings!(HT.get_request_context(request), request_timeout_ns, timeout_config) + request.context = ctx + request_timeout_ns, timeout_config = HT._resolve_request_timeout_settings(0, 0, 0, 0, 60) + HT._apply_request_timeout_settings!(ctx, request_timeout_ns, timeout_config) @test_throws Reseau.IOPoll.DeadlineExceededError HT.h2_roundtrip!(h2_conn, request) + take!(data_received) + put!(release_server, nothing) _wait_task_h2!(server_task) finally + isready(release_server) || HTTP.@try_ignore put!(release_server, nothing) close(h2_conn) HTTP.@try_ignore NC.close(listener) + HTTP.@try_ignore fetch(server_task) end end @@ -1291,10 +1276,9 @@ end client = HT.Client() ctx = HT.RequestContext() url = "http://$(address)/cancel" - request_task = Threads.@spawn HT.get($url; protocol = :h2, context = $ctx, client = $client, retry = false) + request_task = @async HT.get(url; protocol=:h2, context=ctx, client=client, retry=false) try - @test timedwait(() -> isready(stream_ids), 5.0; pollint = 0.001) != :timed_out - isready(stream_ids) && take!(stream_ids) + take!(stream_ids) HT.cancel!(ctx; message = "user canceled h2") result = try fetch(request_task) @@ -1307,8 +1291,7 @@ end @test result[2] isa HT.CanceledError @test (result[2]::HT.CanceledError).message == "user canceled h2" @test isempty(ctx.cancel_callbacks) - @test timedwait(() -> isready(reset_codes), 5.0; pollint = 0.001) != :timed_out - isready(reset_codes) && @test take!(reset_codes) == UInt32(0x8) + @test take!(reset_codes) == UInt32(0x8) _wait_task_h2!(server_task) finally close(client) @@ -1594,6 +1577,7 @@ end listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 8) laddr = NC.addr(listener)::NC.SocketAddrV4 address = ND.join_host_port("127.0.0.1", Int(laddr.port)) + finish = Channel{Nothing}(1) server_task = errormonitor(Threads.@spawn begin accepted_conn = NC.accept(listener) reader = HT._ConnReader(accepted_conn) @@ -1613,7 +1597,7 @@ end frame isa HT.DataFrame && break end _write_frame_to_conn!(accepted_conn, HT.RSTStreamFrame(hf.stream_id, UInt32(0x8))) - sleep(0.20) + take!(finish) finally HTTP.@try_ignore NC.close(accepted_conn) end @@ -1633,8 +1617,10 @@ end @test (err::HT.H2StreamResetError).error_code == UInt32(0x8) # A stream-level reset must not poison the shared connection. @test HT._h2_conn_reusable(h2_conn) + put!(finish, nothing) _wait_task_h2!(server_task) finally + isready(finish) || HTTP.@try_ignore put!(finish, nothing) close(h2_conn) HTTP.@try_ignore NC.close(listener) end @@ -1667,47 +1653,29 @@ end _ = HT.read_frame!(reader) hf = (_read_next_headers_frame!(reader))::HT.HeadersFrame _ = HT.decode_header_block(server_decoder, hf.header_block_fragment) - # Drain everything the client sends without ever granting connection - # credit, until it stops emitting DATA. A compliant client exhausts the - # default 65535-byte connection window and then stalls, which we observe - # as a read deadline rather than more bytes. A client that wrongly leaned - # on its 1 MiB stream window would keep sending and reach END_STREAM here, - # so the drained byte count is what proves the connection-level cap. + # Drain exactly the default connection window before granting more + # credit. The final frame must end at 65535 bytes. An oversized frame + # proves that the client used the larger stream window by mistake. got_end = false - NC.set_deadline!(accepted_conn, Int64(time_ns() + 1_000_000_000)) - try - while !got_end - frame = HT.read_frame!(reader) - frame isa HT.DataFrame || continue - df = frame::HT.DataFrame - df.stream_id == hf.stream_id || continue - bytes_before_grant[] += length(df.data) - got_end = df.end_stream - end - catch err - err isa Reseau.IOPoll.DeadlineExceededError || rethrow(err) - finally - NC.set_deadline!(accepted_conn, Int64(0)) + while bytes_before_grant[] < HT._H2_DEFAULT_WINDOW_SIZE + frame = HT.read_frame!(reader) + frame isa HT.DataFrame || continue + df = frame::HT.DataFrame + df.stream_id == hf.stream_id || continue + bytes_before_grant[] += length(df.data) + got_end = df.end_stream end total_received[] = bytes_before_grant[] - # A compliant client stalled mid-body; release the rest of the connection - # window and finish reading. If it already hit END_STREAM (the overshoot - # case), skip this so a regression fails the assertion rather than hanging. - if !got_end - _write_frame_to_conn!(accepted_conn, HT.WindowUpdateFrame(UInt32(0), UInt32(payload_len - HT._H2_DEFAULT_WINDOW_SIZE))) - NC.set_deadline!(accepted_conn, Int64(time_ns() + 2_000_000_000)) - try - while !got_end - frame = HT.read_frame!(reader) - frame isa HT.DataFrame || continue - df = frame::HT.DataFrame - df.stream_id == hf.stream_id || continue - total_received[] += length(df.data) - got_end = df.end_stream - end - finally - NC.set_deadline!(accepted_conn, Int64(0)) - end + @test bytes_before_grant[] == HT._H2_DEFAULT_WINDOW_SIZE + @test !got_end + _write_frame_to_conn!(accepted_conn, HT.WindowUpdateFrame(UInt32(0), UInt32(payload_len - HT._H2_DEFAULT_WINDOW_SIZE))) + while !got_end + frame = HT.read_frame!(reader) + frame isa HT.DataFrame || continue + df = frame::HT.DataFrame + df.stream_id == hf.stream_id || continue + total_received[] += length(df.data) + got_end = df.end_stream end encoded = HT.encode_header_block(server_encoder, HT.HeaderField[HT.HeaderField(":status", "200", false)]) _write_frame_to_conn!(accepted_conn, HT.HeadersFrame(hf.stream_id, false, true, encoded)) @@ -1832,8 +1800,6 @@ end req = HT.Request("GET", "/two"; host = address, body = HT.EmptyBody(), content_length = 0) return HT.h2_roundtrip!(h2_conn, req) end) - @test timedwait(() -> istaskdone(t1), 3.0; pollint = 0.001) != :timed_out - @test timedwait(() -> istaskdone(t2), 3.0; pollint = 0.001) != :timed_out r1 = fetch(t1) r2 = fetch(t2) @test r1.status == 200 @@ -1917,6 +1883,8 @@ end end) h2_conn = HT.connect_h2!(address; secure = false) try + # One task is expected to fail with H2GoAwayError, so these tasks are + # intentionally not wrapped in errormonitor. t1 = Threads.@spawn begin req = HT.Request("GET", "/one"; host = address, body = HT.EmptyBody(), content_length = 0) return HT.h2_roundtrip!(h2_conn, req) @@ -1925,8 +1893,6 @@ end req = HT.Request("GET", "/two"; host = address, body = HT.EmptyBody(), content_length = 0) return HT.h2_roundtrip!(h2_conn, req) end - @test timedwait(() -> istaskdone(t1), 3.0; pollint = 0.001) != :timed_out - @test timedwait(() -> istaskdone(t2), 3.0; pollint = 0.001) != :timed_out outcomes = Any[] for task in (t1, t2) push!(outcomes, try @@ -1974,7 +1940,6 @@ end conn = TL.accept(listener) try TL.handshake!(conn) - sleep(0.05) catch err # The client intentionally aborts once ALPN negotiation fails. # Server-side TLS failures are expected in this test path. @@ -2046,9 +2011,15 @@ end try request = HT.Request("GET", "/"; host = addr, body = HT.EmptyBody(), content_length = 0, proto_major = 2, proto_minor = 0) response = HT.h2_roundtrip!(conn, request) - # Allow the trailing HEADERS frame to land. The fix surfaces it - # whether it arrives before or after the response head is built. - sleep(0.1) + state = (response.body::HT.H2Body).state + lock(state.lock) + try + while !state.stream_done + wait(state.condition) + end + finally + unlock(state.lock) + end @test response.status == 200 @test HT.header(response.trailers, "grpc-status") == "0" finally @@ -2376,7 +2347,7 @@ end finally conn === nothing || close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -2387,8 +2358,8 @@ function _build_bare_h2_connection() listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 1) laddr = NC.addr(listener)::NC.SocketAddrV4 address = ND.join_host_port("127.0.0.1", Int(laddr.port)) - accept_task = Threads.@spawn NC.accept(listener) - client_tcp = NC.connect("tcp", address; timeout_ns = 3_000_000_000) + accept_task = errormonitor(Threads.@spawn NC.accept(listener)) + client_tcp = NC.connect("tcp", address) server_tcp = fetch(accept_task) state_lock = ReentrantLock() conn = HT.H2Connection( @@ -2426,7 +2397,7 @@ function _build_bare_h2_connection() nothing, UInt8[], 0, - Int64(time_ns()), + Int64(0), false, ) cleanup = () -> begin @@ -2437,6 +2408,60 @@ function _build_bare_h2_connection() return conn, cleanup end +@testset "HTTP/2 client send-window waiter lifecycle" begin + conn, cleanup = _build_bare_h2_connection() + stream_id = UInt32(1) + wait_calls = Ref(0) + try + lock(conn.state_lock) + try + conn.stream_send_window[stream_id] = 0 + conn.conn_send_window = 0 + @test !HT._h2_send_window_ready_locked(conn, stream_id) + + @atomic :release conn.closed = true + @test HT._h2_send_window_ready_locked(conn, stream_id) + @atomic :release conn.closed = false + + conn.conn_error = HT.ProtocolError("test connection error") + @test HT._h2_send_window_ready_locked(conn, stream_id) + conn.conn_error = nothing + + push!(conn.send_closed_streams, stream_id) + @test HT._h2_send_window_ready_locked(conn, stream_id) + delete!(conn.send_closed_streams, stream_id) + + delete!(conn.stream_send_window, stream_id) + @test HT._h2_send_window_ready_locked(conn, stream_id) + + conn.stream_send_window[stream_id] = 1 + conn.conn_send_window = 1 + @test HT._h2_send_window_ready_locked(conn, stream_id) + + conn.stream_send_window[stream_id] = 0 + conn.conn_send_window = 0 + wait_for = (predicate, _timeout_seconds; kwargs...) -> begin + wait_calls[] += 1 + @test !predicate() + return :timed_out + end + @test_throws Reseau.IOPoll.DeadlineExceededError HT._wait_h2_send_window_locked!( + conn, + stream_id, + Int64(10); + clock_ns = () -> Int64(0), + wait_for = wait_for, + ) + @test conn.stream_send_window[stream_id] == 0 + finally + unlock(conn.state_lock) + end + @test wait_calls[] == 1 + finally + cleanup() + end +end + # Regression for the HPACK desync when a HEADERS/CONTINUATION block arrives for # an unknown/closed stream (ANT-2026-SCEWC4G3 / RFC 7541 §4). The client must # still pass every header block through `conn.decoder` so the connection-scoped @@ -2650,22 +2675,21 @@ end HTTP.@try_ignore NC.close(c2) return nothing end) - client = HT.Client(transport = HT.Transport(idle_timeout_ns = Int64(150_000_000))) # 150ms + client = HT.Client(transport=HT.Transport(idle_timeout_ns=1)) try r1 = HT.get("http://$(address)/one"; client = client, protocol = :h2) @test r1.status == 200 pooled = lock(client.h2_lock) do only(only(values(client.h2_conns))) end - sleep(0.4) # exceed the idle timeout - started = time() + lock(pooled.state_lock) do + pooled.idle_since_ns = 1 + end r2 = HT.get("http://$(address)/two"; client = client, protocol = :h2, retry = false) - elapsed = time() - started @test r2.status == 200 - @test elapsed < 5.0 # a reused dead connection would hang @test accepts[] == 2 # request 2 ran on a fresh connection # the evicted connection was closed, and the pool holds only the new one - @test timedwait(() -> (@atomic :acquire pooled.closed), 5.0; pollint = 0.01) == :ok + @test @atomic :acquire pooled.closed n = lock(client.h2_lock) do sum(length(v) for v in values(client.h2_conns); init = 0) end @@ -2699,7 +2723,6 @@ end for i in 1:3 r = HT.get("http://$(address)/r$(i)"; client = client, protocol = :h2) @test r.status == 200 - sleep(0.05) end @test accepts[] == 1 # every request reused the single pooled conn _wait_task_h2!(server_task) @@ -2748,7 +2771,7 @@ end sum(length(v) for v in values(client.h2_conns); init = 0) end @test remaining == 0 - @test timedwait(() -> (@atomic :acquire conn.closed), 5.0; pollint = 0.01) == :ok + @test @atomic :acquire conn.closed finally close(client) HTTP.@try_ignore NC.close(listener) diff --git a/test/http2_server_tests.jl b/test/http2_server_tests.jl index 4de371200..2d92fb6ad 100644 --- a/test/http2_server_tests.jl +++ b/test/http2_server_tests.jl @@ -36,28 +36,21 @@ function _write_frame_h2_server_raw!(conn::NC.Conn, frame::HT.AbstractFrame) return nothing end -const _RAW_H2_SERVER_FRAME_TIMEOUT_S = Sys.iswindows() ? 10.0 : 5.0 - -function _read_h2_server_frame!(conn::NC.Conn, reader::IO; timeout_s::Float64 = _RAW_H2_SERVER_FRAME_TIMEOUT_S) - NC.set_deadline!(conn, Int64(time_ns() + round(Int, timeout_s * 1_000_000_000))) - try - return HT.read_frame!(reader) - finally - NC.set_deadline!(conn, Int64(0)) - end +function _read_h2_server_frame!(conn::NC.Conn, reader::IO) + _ = conn + return HT.read_frame!(reader) end function _open_raw_h2_server_conn( address::String; settings::Vector{Pair{UInt16, UInt32}} = Pair{UInt16, UInt32}[], - timeout_s::Float64 = _RAW_H2_SERVER_FRAME_TIMEOUT_S, ) conn = ND.connect("tcp", address) reader = HT._ConnReader(conn) _write_all_h2_server_raw!(conn, HT._H2_PREFACE) _write_frame_h2_server_raw!(conn, HT.SettingsFrame(false, settings)) - first = _read_h2_server_frame!(conn, reader; timeout_s) - second = _read_h2_server_frame!(conn, reader; timeout_s) + first = _read_h2_server_frame!(conn, reader) + second = _read_h2_server_frame!(conn, reader) frames = (first, second) count(frame -> frame isa HT.SettingsFrame && !(frame::HT.SettingsFrame).ack, frames) == 1 || error("expected server SETTINGS frame") count(frame -> frame isa HT.SettingsFrame && (frame::HT.SettingsFrame).ack, frames) == 1 || error("expected server SETTINGS ACK frame") @@ -118,17 +111,13 @@ function _read_h2_server_data_frame!(conn::NC.Conn, reader::IO)::HT.DataFrame end end -function _read_h2_server_frame_until!(conn::NC.Conn, reader::IO, predicate; timeout_s::Float64 = 1.0) - NC.set_deadline!(conn, Int64(time_ns() + round(Int, timeout_s * 1_000_000_000))) - try - for _ in 1:64 - frame = HT.read_frame!(reader) - predicate(frame) && return frame - end - error("timed out waiting for expected HTTP/2 frame") - finally - NC.set_deadline!(conn, Int64(0)) +function _read_h2_server_frame_until!(conn::NC.Conn, reader::IO, predicate) + _ = conn + for _ in 1:64 + frame = HT.read_frame!(reader) + predicate(frame) && return frame end + error("expected HTTP/2 frame was not found") end function _read_h2_server_text_response!(conn::NC.Conn, reader::IO, decoder::HT.Decoder, stream_id::UInt32)::String @@ -167,30 +156,25 @@ end function _drain_h2_server_responses!(conn::NC.Conn, reader::IO, decoder::HT.Decoder, expected::Set{UInt32}) bodies = Dict{UInt32, Vector{UInt8}}(id => UInt8[] for id in expected) done = Set{UInt32}() - NC.set_deadline!(conn, Int64(time_ns() + 5_000_000_000)) - try - while length(done) < length(expected) - frame = HT.read_frame!(reader) - if frame isa HT.HeadersFrame - hf = frame::HT.HeadersFrame - # Decode regardless of stream to keep the decoder in sync. - decoded = HT.decode_header_block(decoder, hf.header_block_fragment) - if hf.stream_id in expected - @test any(field -> field.name == ":status" && field.value == "200", decoded) - hf.end_stream && push!(done, hf.stream_id) - end - elseif frame isa HT.DataFrame - df = frame::HT.DataFrame - if df.stream_id in expected - append!(bodies[df.stream_id], df.data) - df.end_stream && push!(done, df.stream_id) - end - elseif frame isa HT.RSTStreamFrame && (frame::HT.RSTStreamFrame).stream_id in expected - error("unexpected RST_STREAM for stream $((frame::HT.RSTStreamFrame).stream_id)") + while length(done) < length(expected) + frame = HT.read_frame!(reader) + if frame isa HT.HeadersFrame + hf = frame::HT.HeadersFrame + # Decode regardless of stream to keep the decoder in sync. + decoded = HT.decode_header_block(decoder, hf.header_block_fragment) + if hf.stream_id in expected + @test any(field -> field.name == ":status" && field.value == "200", decoded) + hf.end_stream && push!(done, hf.stream_id) end + elseif frame isa HT.DataFrame + df = frame::HT.DataFrame + if df.stream_id in expected + append!(bodies[df.stream_id], df.data) + df.end_stream && push!(done, df.stream_id) + end + elseif frame isa HT.RSTStreamFrame && (frame::HT.RSTStreamFrame).stream_id in expected + error("unexpected RST_STREAM for stream $((frame::HT.RSTStreamFrame).stream_id)") end - finally - NC.set_deadline!(conn, Int64(0)) end return Dict(id => String(bodies[id]) for id in expected) end @@ -226,7 +210,7 @@ end finally close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -245,7 +229,7 @@ end finally close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -258,8 +242,8 @@ end _ = request return HT.sse_stream(200) do stream write(stream, HT.SSEEvent("one")) - delivered = timedwait(() -> isready(saw_first), 5.0; pollint = 0.01) - write(stream, HT.SSEEvent(delivered === :ok ? "two" : "first event was not delivered before close")) + take!(saw_first) + write(stream, HT.SSEEvent("two")) end end address = HT.server_addr(server) @@ -277,7 +261,7 @@ end finally close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -307,7 +291,7 @@ end finally close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -341,7 +325,7 @@ end finally close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -368,7 +352,7 @@ end finally close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -405,7 +389,7 @@ end finally HTTP.@try_ignore NC.close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -436,7 +420,7 @@ end finally close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -473,7 +457,7 @@ end finally close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end end @@ -511,19 +495,24 @@ end finally close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end end @testset "HTTP/2 server request handler timeout middleware" begin - handler = HT.Handlers.handlertimeout(0.05)(request -> begin - if request.target == "/fast" - return HT.Response(200, HT.BytesBody(UInt8[0x6f, 0x6b]); content_length = 2, proto_major = 2, proto_minor = 0) - end - sleep(0.15) + slow_started = Channel{HT.RequestContext}(1) + release_slow = Channel{Nothing}(1) + slow_handler = HT.Handlers.handlertimeout(1.0e-9)(request -> begin + put!(slow_started, HT.get_request_context(request)) + take!(release_slow) return HT.Response(200, HT.BytesBody(UInt8[0x6c, 0x61, 0x74, 0x65]); content_length = 4, proto_major = 2, proto_minor = 0) end) + fast_handler = HT.Handlers.handlertimeout(60.0)(request -> begin + _ = request + return HT.Response(200, HT.BytesBody(UInt8[0x6f, 0x6b]); content_length = 2, proto_major = 2, proto_minor = 0) + end) + handler = request -> request.target == "/fast" ? fast_handler(request) : slow_handler(request) server = HT.serve!(handler, "127.0.0.1", 0; listenany = true) address = HT.server_addr(server) conn = HT.connect_h2!(address; secure = false) @@ -532,6 +521,9 @@ end slow_res = HT.h2_roundtrip!(conn, slow_req) @test slow_res.status == 503 @test String(_read_all_h2_server(slow_res.body)) == "handler timed out" + slow_context = take!(slow_started) + @test HT.canceled(slow_context) + put!(release_slow, nothing) fast_req = HT.Request("GET", "/fast"; host = address, body = HT.EmptyBody(), content_length = 0, proto_major = 2, proto_minor = 0) fast_res = HT.h2_roundtrip!(conn, fast_req) @@ -540,7 +532,7 @@ end finally close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -567,7 +559,7 @@ end finally conn === nothing || HTTP.@try_ignore NC.close(conn::NC.Conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -607,7 +599,7 @@ end finally HTTP.@try_ignore NC.close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -624,36 +616,31 @@ end _write_h2_server_request_headers!(conn, encoder, UInt32(1), address, "/bad-trailer"; end_stream = false) bad_trailer_block = HT.encode_header_block(encoder, HT.HeaderField[HT.HeaderField(":path", "/oops", false)]) _write_frame_h2_server_raw!(conn, HT.HeadersFrame(UInt32(1), true, true, bad_trailer_block)) - NC.set_deadline!(conn::NC.Conn, Int64(time_ns() + 1_000_000_000)) saw_goaway = false saw_exception = false goaway_error_code = UInt32(0) - try - while true - frame_or_err = try - HT.read_frame!(reader) - catch err - err - end - if frame_or_err isa HT.GoAwayFrame - saw_goaway = true - goaway_error_code = (frame_or_err::HT.GoAwayFrame).error_code - break - end - if frame_or_err isa Exception - saw_exception = true - break - end + while true + frame_or_err = try + HT.read_frame!(reader) + catch err + err + end + if frame_or_err isa HT.GoAwayFrame + saw_goaway = true + goaway_error_code = (frame_or_err::HT.GoAwayFrame).error_code + break + end + if frame_or_err isa Exception + saw_exception = true + break end - finally - NC.set_deadline!(conn::NC.Conn, Int64(0)) end @test saw_goaway || saw_exception saw_goaway && @test goaway_error_code == UInt32(0x1) finally conn === nothing || HTTP.@try_ignore NC.close(conn::NC.Conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -681,7 +668,7 @@ end finally HTTP.@try_ignore NC.close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -715,7 +702,7 @@ end finally HTTP.@try_ignore NC.close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -743,7 +730,7 @@ end finally HTTP.@try_ignore NC.close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -773,7 +760,7 @@ end finally HTTP.@try_ignore NC.close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -812,7 +799,7 @@ end finally HTTP.@try_ignore NC.close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -854,7 +841,7 @@ end finally close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -900,7 +887,7 @@ end finally close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -927,10 +914,8 @@ end _write_h2_server_request_headers!(conn::NC.Conn, encoder, UInt32(1), address, "/streaming") take!(first_written) - NC.set_read_deadline!(conn::NC.Conn, Int64(time_ns() + 1_000_000_000)) headers_frame, header_block, _ = _read_h2_server_header_block!(conn, reader) first_data = _read_h2_server_data_frame!(conn, reader) - NC.set_read_deadline!(conn::NC.Conn, Int64(0)) decoded_headers = HT.decode_header_block(decoder, header_block) first_payload = copy(first_data.data) @@ -951,7 +936,7 @@ end isready(release) || put!(release, nothing) conn === nothing || HTTP.@try_ignore NC.close(conn::NC.Conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -981,7 +966,7 @@ end finally close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -1008,7 +993,7 @@ end finally close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -1061,14 +1046,8 @@ end () -> nothing, ) request = HT.Request("POST", "/stream-body"; host = address, body = request_body, content_length = 11, proto_major = 2, proto_minor = 0) - response_task = Threads.@spawn HT.h2_roundtrip!(conn, request) - final_requested = timedwait(() -> isready(final_chunk_requested), 3.0; pollint = 0.001) - @test final_requested != :timed_out - final_requested == :timed_out && error("timed out waiting for final request chunk") + response_task = errormonitor(Threads.@spawn HT.h2_roundtrip!(conn, request)) take!(final_chunk_requested) - first_seen = timedwait(() -> isready(first_chunk_seen), 3.0; pollint = 0.001) - @test first_seen != :timed_out - first_seen == :timed_out && error("timed out waiting for handler to read first chunk") first_chunk = take!(first_chunk_seen) @test first_chunk == "hello" put!(release_final_chunk, nothing) @@ -1079,13 +1058,16 @@ end HTTP.@try_ignore isready(release_final_chunk) || put!(release_final_chunk, nothing) close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @testset "HTTP/2 server handles concurrent streams on one connection" begin + entered = Channel{String}(2) + release = Channel{Nothing}(2) server = HT.serve!("127.0.0.1", 0; listenany = true) do request - sleep(1.0) + put!(entered, request.target) + take!(release) payload = collect(codeunits(request.target)) return HT.Response(200, HT.BytesBody(payload); content_length = length(payload), proto_major = 2, proto_minor = 0) end @@ -1094,27 +1076,27 @@ end try req1 = HT.Request("GET", "/one"; host = address, body = HT.EmptyBody(), content_length = 0, proto_major = 2, proto_minor = 0) req2 = HT.Request("GET", "/two"; host = address, body = HT.EmptyBody(), content_length = 0, proto_major = 2, proto_minor = 0) - started = time() - task1 = Threads.@spawn begin + task1 = errormonitor(Threads.@spawn begin response = HT.h2_roundtrip!(conn, req1) return (response.status, String(_read_all_h2_server(response.body))) - end - sleep(0.1) - task2 = Threads.@spawn begin + end) + task2 = errormonitor(Threads.@spawn begin response = HT.h2_roundtrip!(conn, req2) return (response.status, String(_read_all_h2_server(response.body))) - end + end) + entered_paths = Set((take!(entered), take!(entered))) + @test entered_paths == Set(("/one", "/two")) + put!(release, nothing) + put!(release, nothing) res1 = fetch(task1) res2 = fetch(task2) - elapsed = time() - started @test res1[1] == 200 @test res2[1] == 200 @test Set((res1[2], res2[2])) == Set(("/one", "/two")) - @test elapsed < 1.75 finally close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -1133,7 +1115,7 @@ end close(conn) end HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) fail_fast_resolver = ND.HostResolver(timeout_ns = Int64(1_000_000_000)) @test_throws Exception HT.connect_h2!(address; secure = false, host_resolver = fail_fast_resolver) end @@ -1189,7 +1171,7 @@ end conn === nothing || HTTP.@try_ignore NC.close(conn::NC.Conn) close_task === nothing || HTTP.@try_ignore fetch(close_task::Task) isopen(server) && HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -1211,7 +1193,7 @@ end finally close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -1238,7 +1220,7 @@ end finally close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -1268,13 +1250,10 @@ end split_idx = max(1, length(header_block) ÷ 2) _write_frame_h2_server_raw!(conn, HT.HeadersFrame(UInt32(1), false, false, header_block[1:split_idx])) _write_frame_h2_server_raw!(conn, HT.DataFrame(UInt32(1), true, UInt8[])) - NC.set_deadline!(conn, Int64(time_ns() + 1_000_000_000)) frame_or_err = try HT.read_frame!(reader) catch err err - finally - NC.set_deadline!(conn, Int64(0)) end @test frame_or_err isa HT.GoAwayFrame || frame_or_err isa Exception if frame_or_err isa HT.GoAwayFrame @@ -1283,7 +1262,7 @@ end finally HTTP.@try_ignore NC.close(conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -1329,13 +1308,10 @@ end try conn, reader = _open_raw_h2_server_conn(address) _write_frame_h2_server_raw!(conn::NC.Conn, HT.HeadersFrame(UInt32(1), true, true, HT.encode_header_block(HT.Encoder(), header_fields))) - NC.set_deadline!(conn::NC.Conn, Int64(time_ns() + 1_000_000_000)) frame_or_err = try HT.read_frame!(reader) catch err err - finally - NC.set_deadline!(conn::NC.Conn, Int64(0)) end @test frame_or_err isa HT.GoAwayFrame || frame_or_err isa Exception if frame_or_err isa HT.GoAwayFrame @@ -1347,7 +1323,7 @@ end end finally HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -1510,7 +1486,7 @@ end end finally HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -1539,7 +1515,7 @@ end HTTP.@try_ignore put!(release, nothing) conn === nothing || HTTP.@try_ignore NC.close(conn::NC.Conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -1566,13 +1542,10 @@ end split_idx = max(1, length(header_block) ÷ 2) _write_frame_h2_server_raw!(conn::NC.Conn, HT.HeadersFrame(UInt32(1), true, false, header_block[1:split_idx])) _write_frame_h2_server_raw!(conn::NC.Conn, HT.ContinuationFrame(UInt32(1), true, header_block[(split_idx + 1):end])) - NC.set_deadline!(conn::NC.Conn, Int64(time_ns() + 1_000_000_000)) frame_or_err = try HT.read_frame!(reader) catch err err - finally - NC.set_deadline!(conn::NC.Conn, Int64(0)) end @test frame_or_err isa HT.GoAwayFrame || frame_or_err isa Exception if frame_or_err isa HT.GoAwayFrame @@ -1581,7 +1554,7 @@ end finally conn === nothing || HTTP.@try_ignore NC.close(conn::NC.Conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -1606,13 +1579,10 @@ end header_block = HT.encode_header_block(encoder, header_fields) @test length(header_block) <= 192 _write_frame_h2_server_raw!(conn::NC.Conn, HT.HeadersFrame(UInt32(1), true, true, header_block)) - NC.set_deadline!(conn::NC.Conn, Int64(time_ns() + 1_000_000_000)) frame_or_err = try HT.read_frame!(reader) catch err err - finally - NC.set_deadline!(conn::NC.Conn, Int64(0)) end @test frame_or_err isa HT.GoAwayFrame || frame_or_err isa Exception if frame_or_err isa HT.GoAwayFrame @@ -1621,7 +1591,7 @@ end finally conn === nothing || HTTP.@try_ignore NC.close(conn::NC.Conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -1668,7 +1638,7 @@ end finally conn === nothing || HTTP.@try_ignore NC.close(conn::NC.Conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -1699,7 +1669,7 @@ end finally conn === nothing || HTTP.@try_ignore NC.close(conn::NC.Conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -1744,17 +1714,6 @@ end received += length((frame::HT.DataFrame).data) end @test received == 65_535 - NC.set_deadline!(conn::NC.Conn, Int64(time_ns() + 250_000_000)) - stalled = try - HT.read_frame!(reader) - false - catch - true - finally - NC.set_deadline!(conn::NC.Conn, Int64(0)) - end - @test stalled - remaining = length(payload) - received _write_frame_h2_server_raw!(conn::NC.Conn, HT.WindowUpdateFrame(UInt32(0), UInt32(remaining))) _write_frame_h2_server_raw!(conn::NC.Conn, HT.WindowUpdateFrame(UInt32(1), UInt32(remaining))) @@ -1771,7 +1730,7 @@ end finally conn === nothing || HTTP.@try_ignore NC.close(conn::NC.Conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -1784,17 +1743,31 @@ end finally unlock(send_state.state_lock) end - deadline_ns = Int64(time_ns()) + Int64(50_000_000) - elapsed = @elapsed begin - @test_throws Reseau.IOPoll.DeadlineExceededError HT._reserve_h2_send_window!(send_state, UInt32(1), 1, deadline_ns) + @test_throws Reseau.IOPoll.DeadlineExceededError HT._reserve_h2_send_window!(send_state, UInt32(1), 1, Int64(1)) + + waited = Int64[] + lock(send_state.state_lock) + try + HT._wait_h2_send_window_locked!( + send_state, + Int64(10); + clock_ns = () -> Int64(4), + wait_ns = ns -> push!(waited, ns), + ) + @test send_state.conn_send_window == 0 + finally + unlock(send_state.state_lock) end - @test elapsed < 0.5 + @test waited == Int64[6] end @testset "HTTP/2 server keeps the connection usable after stream resets" begin + cancel_started = Channel{Nothing}(1) + release_cancel = Channel{Nothing}(1) server = HT.serve!("127.0.0.1", 0; listenany = true) do request if request.target == "/cancel" - sleep(0.3) + put!(cancel_started, nothing) + take!(release_cancel) return HT.Response(200, HT.BytesBody(collect(codeunits("cancel"))); content_length = 6, proto_major = 2, proto_minor = 0) end return HT.Response(200, HT.BytesBody(collect(codeunits("ok"))); content_length = 2, proto_major = 2, proto_minor = 0) @@ -1806,6 +1779,7 @@ end encoder = HT.Encoder() decoder = HT.Decoder() _write_h2_server_request_headers!(conn::NC.Conn, encoder, UInt32(1), address, "/cancel") + take!(cancel_started) _write_frame_h2_server_raw!(conn::NC.Conn, HT.RSTStreamFrame(UInt32(1), UInt32(0x8))) _write_h2_server_request_headers!(conn::NC.Conn, encoder, UInt32(3), address, "/ok") @@ -1836,10 +1810,12 @@ end end @test saw_headers @test String(response_body) == "ok" + put!(release_cancel, nothing) finally + isready(release_cancel) || HTTP.@try_ignore put!(release_cancel, nothing) HTTP.@try_ignore NC.close(conn::NC.Conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -1885,14 +1861,13 @@ end @test (HT._H2_SETTINGS_MAX_CONCURRENT_STREAMS => UInt32(17)) in server_settings.settings @test (HT._H2_SETTINGS_INITIAL_WINDOW_SIZE => UInt32(1_048_576)) in server_settings.settings wu = _read_h2_server_frame_until!(conn, reader, - f -> f isa HT.WindowUpdateFrame && (f::HT.WindowUpdateFrame).stream_id == UInt32(0); - timeout_s = 3.0)::HT.WindowUpdateFrame + f -> f isa HT.WindowUpdateFrame && (f::HT.WindowUpdateFrame).stream_id == UInt32(0))::HT.WindowUpdateFrame @test wu.stream_id == UInt32(0) @test wu.window_size_increment == UInt32(2_097_152 - 65_535) finally conn === nothing || HTTP.@try_ignore NC.close(conn::NC.Conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -1911,7 +1886,7 @@ end finally conn === nothing || HTTP.@try_ignore NC.close(conn::NC.Conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -1963,8 +1938,7 @@ end # error code must be REFUSED_STREAM (0x7), not a normal response. _write_h2_server_request_headers!(conn, encoder, UInt32(5), address, "/c") rst = _read_h2_server_frame_until!(conn, reader, - f -> f isa HT.RSTStreamFrame && (f::HT.RSTStreamFrame).stream_id == UInt32(5); - timeout_s = 3.0) + f -> f isa HT.RSTStreamFrame && (f::HT.RSTStreamFrame).stream_id == UInt32(5)) @test (rst::HT.RSTStreamFrame).error_code == HT._H2_ERROR_REFUSED_STREAM # The refused stream's HPACK header block must still be decoded into # the shared decoder so a later legitimate stream stays in sync. Free @@ -1986,7 +1960,7 @@ end end finally HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end @@ -2008,13 +1982,13 @@ end # (GOAWAY) rather than dispatching the handler again. _write_h2_server_request_headers!(conn, encoder, UInt32(3), address, "/replay") goaway = _read_h2_server_frame_until!(conn, reader, - f -> f isa HT.GoAwayFrame; timeout_s = 3.0) + f -> f isa HT.GoAwayFrame) @test goaway isa HT.GoAwayFrame @test (goaway::HT.GoAwayFrame).error_code == HT._H2_ERROR_PROTOCOL finally HTTP.@try_ignore NC.close(conn::NC.Conn) HT.forceclose(server) - _ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001) + HTTP.@try_ignore wait(server.serve_task::Task) end end diff --git a/test/http_client_proxy_tests.jl b/test/http_client_proxy_tests.jl index 749b4b9bb..0f35661dd 100644 --- a/test/http_client_proxy_tests.jl +++ b/test/http_client_proxy_tests.jl @@ -131,9 +131,7 @@ function _send_response_proxy!(conn::NC.Conn, request::HT.Request; status::Int = return nothing end -function _wait_task_proxy!(task::Task; timeout_s::Float64 = 5.0) - status = timedwait(() -> istaskdone(task), timeout_s; pollint = 0.001) - status == :timed_out && error("timed out waiting for proxy task") +function _wait_task_proxy!(task::Task) fetch(task) return nothing end @@ -261,8 +259,8 @@ function _proxy_windows_ci_warmup!()::Nothing bridge1 = errormonitor(Threads.@spawn _bridge_proxy!(client_conn, origin_conn)) bridge2 = errormonitor(Threads.@spawn _bridge_proxy!(origin_conn, client_conn)) HTTP.@try_ignore begin - _wait_task_proxy!(bridge1; timeout_s = 2.0) - _wait_task_proxy!(bridge2; timeout_s = 2.0) + _wait_task_proxy!(bridge1) + _wait_task_proxy!(bridge2) end finally HTTP.@try_ignore NC.close(client_conn) @@ -285,15 +283,14 @@ function _proxy_windows_ci_warmup!()::Nothing prefer_http2 = false, ) request = HT.Request("GET", "/warmup"; host = origin_address, body = HT.EmptyBody(), content_length = 0) - HT.set_deadline!(HT.get_request_context(request), Int64(time_ns()) + 3_000_000_000) HTTP.@try_ignore begin request_task = errormonitor(Threads.@spawn HT.do!(client, origin_address, request; secure = true, protocol = :h1)) - _wait_task_proxy!(request_task; timeout_s = 2.0) + _wait_task_proxy!(request_task) response = fetch(request_task) _read_all_proxy(response.body) close(client) - _wait_task_proxy!(origin_task; timeout_s = 2.0) - _wait_task_proxy!(proxy_task; timeout_s = 2.0) + _wait_task_proxy!(origin_task) + _wait_task_proxy!(proxy_task) end catch # The warmup is best-effort: it exists only to exercise the flaky @@ -303,13 +300,13 @@ function _proxy_windows_ci_warmup!()::Nothing HTTP.@try_ignore origin_listener === nothing || TL.close(origin_listener) HTTP.@try_ignore proxy_listener === nothing || NC.close(proxy_listener) origin_task === nothing || HTTP.@try_ignore begin - _wait_task_proxy!(origin_task; timeout_s = 0.5) + _wait_task_proxy!(origin_task) end proxy_task === nothing || HTTP.@try_ignore begin - _wait_task_proxy!(proxy_task; timeout_s = 0.5) + _wait_task_proxy!(proxy_task) end request_task === nothing || HTTP.@try_ignore begin - _wait_task_proxy!(request_task; timeout_s = 0.5) + _wait_task_proxy!(request_task) end GC.gc() yield() @@ -916,8 +913,8 @@ end _socks_write_success_proxy!(client_conn) bridge1 = errormonitor(Threads.@spawn _bridge_proxy!(client_conn, origin_conn)) bridge2 = errormonitor(Threads.@spawn _bridge_proxy!(origin_conn, client_conn)) - _wait_task_proxy!(bridge1; timeout_s = 5.0) - _wait_task_proxy!(bridge2; timeout_s = 5.0) + _wait_task_proxy!(bridge1) + _wait_task_proxy!(bridge2) finally HTTP.@try_ignore NC.close(client_conn) HTTP.@try_ignore NC.close(origin_conn) @@ -939,7 +936,6 @@ end ) try request = HT.Request("GET", "/via-socks-tls"; host = target_address, body = HT.EmptyBody(), content_length = 0) - HT.set_deadline!(HT.get_request_context(request), Int64(time_ns()) + 3_000_000_000) response = HT.do!(client, target_address, request; secure = true, protocol = :h1) @test response.status == 200 @test String(_read_all_proxy(response.body)) == "tls-socks-proxied" @@ -1053,8 +1049,8 @@ end _send_response_proxy!(client_conn, connect_req; status = 200, reason = "Connection Established", headers = headers) bridge1 = errormonitor(Threads.@spawn _bridge_proxy!(client_conn, origin_conn)) bridge2 = errormonitor(Threads.@spawn _bridge_proxy!(origin_conn, client_conn)) - _wait_task_proxy!(bridge1; timeout_s = 5.0) - _wait_task_proxy!(bridge2; timeout_s = 5.0) + _wait_task_proxy!(bridge1) + _wait_task_proxy!(bridge2) finally HTTP.@try_ignore NC.close(client_conn) HTTP.@try_ignore NC.close(origin_conn) @@ -1076,7 +1072,6 @@ end ) try request = HT.Request("GET", "/via-proxy"; host = origin_address, body = HT.EmptyBody(), content_length = 0) - HT.set_deadline!(HT.get_request_context(request), Int64(time_ns()) + 3_000_000_000) response = HT.do!(client, origin_address, request; secure = true, protocol = :h1) @test response.status == 200 @test String(_read_all_proxy(response.body)) == "tls-proxied" diff --git a/test/http_client_tests.jl b/test/http_client_tests.jl index eb76c3b4e..7186dd2c1 100644 --- a/test/http_client_tests.jl +++ b/test/http_client_tests.jl @@ -63,9 +63,7 @@ function _deflate_bytes_client(text::String)::Vector{UInt8} return transcode(HTTP.CodecZlib.ZlibCompressor, collect(codeunits(text))) end -function _wait_task_client!(task::Task; timeout_s::Float64 = 5.0) - status = timedwait(() -> istaskdone(task), timeout_s; pollint = 0.001) - status == :timed_out && error("timed out waiting for server task") +function _wait_task_client!(task::Task) fetch(task) return nothing end @@ -625,8 +623,6 @@ end headers = HT.Headers() HT.setheader(headers, "Location", "/final") _send_response_client!(conn, req; status = 307, reason = "Temporary Redirect", headers = headers, body_text = "redirect") - status = timedwait(() -> isready(server_close), 5.0; pollint = 0.001) - status == :timed_out && error("timed out waiting for 307 test client to finish") take!(server_close) finally HTTP.@try_ignore NC.close(conn) @@ -2385,7 +2381,8 @@ end @test do_response !== nothing @test do_response.body isa HT.SSEStream do_stream = do_response.body::HT.SSEStream - @test timedwait(() -> !isopen(do_stream), 5.0; pollint = 0.001) == :ok + @test String(_read_all_body_bytes_client(do_stream)) == "data: hello\n\n" + @test !isopen(do_stream) end parsed_from_stream = HT.SSEEvent[] @@ -2445,12 +2442,11 @@ end @test config.expect_continue_timeout_ns == 1_500_000_000 ctx = HT.RequestContext() - HT._apply_request_timeout_settings!(ctx, request_timeout_ns, config) + HT._apply_request_timeout_settings!(ctx, request_timeout_ns, config; now_ns=100) stored = ctx.timeout_config @test stored !== nothing @test stored == config - @test ctx.deadline_ns > time_ns() - @test !HT.expired(ctx) + @test ctx.deadline_ns == 1_250_000_100 empty!(ctx) @test ctx.timeout_config === nothing @@ -2478,37 +2474,21 @@ end end @testset "HTTP high-level readtimeout" begin - if _http_windows_ci() - @test_skip true - else listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 8) laddr = NC.addr(listener)::NC.SocketAddrV4 address = ND.join_host_port("127.0.0.1", Int(laddr.port)) base_url = "http://$(address)" - server_task = errormonitor(Threads.@spawn begin - conn = NC.accept(listener) - try - req = HT.read_request(HT._ConnReader(conn)) - _ = req - sleep(0.20) - finally - HTTP.@try_ignore NC.close(conn) - end - return nothing - end) try err = try - HT.get("$(base_url)/slow"; readtimeout = 0.05) + HT.get("$(base_url)/slow"; readtimeout=1.0e-9, retry=false) nothing catch ex ex end @test err isa HT.TimeoutError - _wait_task_client!(server_task) finally HTTP.@try_ignore NC.close(listener) end - end end @testset "HTTP high-level response_header_timeout" begin @@ -2516,26 +2496,14 @@ end laddr = NC.addr(listener)::NC.SocketAddrV4 address = ND.join_host_port("127.0.0.1", Int(laddr.port)) base_url = "http://$(address)" - server_task = errormonitor(Threads.@spawn begin - conn = NC.accept(listener) - try - req = HT.read_request(HT._ConnReader(conn)) - _ = req - sleep(0.20) - finally - HTTP.@try_ignore NC.close(conn) - end - return nothing - end) try err = try - HT.get("$(base_url)/slow"; response_header_timeout=0.05) + HT.get("$(base_url)/slow"; response_header_timeout=1.0e-9, retry=false) nothing catch ex ex end @test err isa HT.TimeoutError - _wait_task_client!(server_task) finally HTTP.@try_ignore NC.close(listener) end @@ -2546,29 +2514,48 @@ end laddr = NC.addr(listener)::NC.SocketAddrV4 address = ND.join_host_port("127.0.0.1", Int(laddr.port)) base_url = "http://$(address)" + partial_body_sent = Channel{Nothing}(1) + release_server = Channel{Nothing}(1) server_task = errormonitor(Threads.@spawn begin conn = NC.accept(listener) try req = HT.read_request(HT._ConnReader(conn)) _ = _read_all_body_bytes_client(req.body) write(conn, collect(codeunits("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\na"))) - sleep(0.20) + put!(partial_body_sent, nothing) + take!(release_server) finally HTTP.@try_ignore NC.close(conn) end return nothing end) + stream = nothing try + ctx = HT.RequestContext() + stream = HT.open(:GET, "$(base_url)/slow-body"; read_idle_timeout=60, context=ctx, retry=false, decompress=false) + response = HT.startread(stream) + @test response.status == 200 + raw_body = ((stream.reader::HT._BodyIO).body::HT.H1Body) + byte = Vector{UInt8}(undef, 1) + @test HT.body_read!(raw_body, byte) == 1 + @test only(byte) == UInt8('a') + take!(partial_body_sent) + HT.set_deadline!(ctx, 1) err = try - HT.get("$(base_url)/slow-body"; read_idle_timeout=0.05) + HT.body_read!(raw_body, byte) nothing catch ex ex end - @test err isa HT.TimeoutError + @test err !== nothing + @test _is_timeout_error_client(err::Exception) + put!(release_server, nothing) _wait_task_client!(server_task) finally + stream === nothing || close(stream) + isready(release_server) || HTTP.@try_ignore put!(release_server, nothing) HTTP.@try_ignore NC.close(listener) + HTTP.@try_ignore fetch(server_task) end end @@ -2576,19 +2563,10 @@ end listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 8) laddr = NC.addr(listener)::NC.SocketAddrV4 address = ND.join_host_port("127.0.0.1", Int(laddr.port)) - server_task = errormonitor(Threads.@spawn begin - conn = NC.accept(listener) - try - sleep(0.20) - finally - HTTP.@try_ignore NC.close(conn) - end - return nothing - end) client = HT.Client(transport=HT.Transport(tls_config=TL.Config(verify_peer=false), max_idle_per_host=4, max_idle_total=4)) try err = try - HT.get("https://$(address)/stall"; client=client, connect_timeout=0.05, retry=false) + HT.get("https://$(address)/stall"; client=client, connect_timeout=1.0e-9, retry=false) nothing catch ex ex @@ -2596,7 +2574,6 @@ end @test err !== nothing @test !(err isa ArgumentError) @test _is_timeout_error_client(err::Exception) - _wait_task_client!(server_task) finally close(client.transport) HTTP.@try_ignore NC.close(listener) @@ -2636,20 +2613,9 @@ end listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 8) laddr = NC.addr(listener)::NC.SocketAddrV4 address = ND.join_host_port("127.0.0.1", Int(laddr.port)) - server_task = errormonitor(Threads.@spawn begin - for _ in 1:2 - conn = NC.accept(listener) - try - sleep(0.20) - finally - HTTP.@try_ignore NC.close(conn) - end - end - return nothing - end) client = HT.Client( transport=HT.Transport(tls_config=TL.Config(verify_peer=false), max_idle_per_host=4, max_idle_total=4), - connect_timeout=0.05, + connect_timeout=1.0e-9, ) try # The request does not pass connect_timeout, so the client default @@ -2674,7 +2640,6 @@ end end @test stream_err !== nothing @test _is_timeout_error_client(stream_err::Exception) - _wait_task_client!(server_task) finally close(client.transport) HTTP.@try_ignore NC.close(listener) @@ -2687,24 +2652,6 @@ end @test wrapped_refused isa HT.ConnectError @test (wrapped_refused::HT.ConnectError).cause === refused - # Connect refused: should wrap to HTTP.ConnectError, never leak Reseau internals. - # Some Windows CI runners report this port-1 probe as a connect timeout - # instead of an immediate refusal, so keep the live probe focused on the - # public HTTPError boundary and test the refusal mapping directly above. - err = try - HT.get("http://127.0.0.1:1/"; connect_timeout=2, retry=false) - nothing - catch ex - ex - end - @test err isa HT.HTTPError - @test err isa HT.ConnectError || err isa HT.TimeoutError - if err isa HT.ConnectError - @test occursin("127.0.0.1", err.address) - else - @test (err::HT.TimeoutError).operation == "connect" - end - # DNS failure: should wrap to HTTP.DNSError. dns_err = try HT.get("http://this-host-does-not-exist.invalid/"; retry=false) @@ -2720,28 +2667,15 @@ end listener = ND.listen("tcp", "127.0.0.1:0"; backlog=8) laddr = NC.addr(listener)::NC.SocketAddrV4 address = ND.join_host_port("127.0.0.1", Int(laddr.port)) - server_task = errormonitor(Threads.@spawn begin - conn = NC.accept(listener) - try - req = HT.read_request(HT._ConnReader(conn)) - _ = req - sleep(0.5) - finally - HTTP.@try_ignore NC.close(conn) - end - return nothing - end) try timeout_err = try - HT.get("http://$(address)/slow"; request_timeout=0.05, retry=false) + HT.get("http://$(address)/slow"; request_timeout=1.0e-9, retry=false) nothing catch ex ex end @test timeout_err isa HT.TimeoutError - @test timeout_err.timeout_ns > 0 - @test timeout_err.elapsed_ns > 0 - _wait_task_client!(server_task) + @test timeout_err.timeout_ns == 1 finally HTTP.@try_ignore NC.close(listener) end @@ -2871,17 +2805,19 @@ end end @testset "RequestContext cancellation interrupts in-flight request" begin + handler_started = Channel{Nothing}(1) + release_handler = Channel{Nothing}(1) slow_server = HT.serve!("127.0.0.1", 0; listenany=true) do req - sleep(60) + put!(handler_started, nothing) + take!(release_handler) return HT.Response(200; body="late") end try ctx = HT.RequestContext() url = "http://127.0.0.1:$(HT.port(slow_server))/" - t = Threads.@spawn HT.get($url; context=$ctx) - sleep(0.5) + t = @async HT.get(url; context=ctx) + take!(handler_started) HT.cancel!(ctx; message="user pressed Ctrl-C") - start = time() result = try fetch(t) (:ok, nothing) @@ -2889,13 +2825,12 @@ end inner = e isa Base.TaskFailedException ? e.task.exception : e (:err, inner) end - elapsed = time() - start @test result[1] == :err @test result[2] isa HT.CanceledError @test (result[2]::HT.CanceledError).message == "user pressed Ctrl-C" - @test elapsed < 5 # cancellation should fire promptly, not wait for sleep(60) @test isempty(ctx.cancel_callbacks) finally + isready(release_handler) || HTTP.@try_ignore put!(release_handler, nothing) HT.forceclose(slow_server) end end @@ -2925,14 +2860,13 @@ end end @testset "Client default_request_timeout applied unless overridden" begin - slow_server = HT.serve!("127.0.0.1", 0; listenany=true) do req - sleep(5) - return HT.Response(200) - end + listener = ND.listen("tcp", "127.0.0.1:0"; backlog=8) + laddr = NC.addr(listener)::NC.SocketAddrV4 + address = ND.join_host_port("127.0.0.1", Int(laddr.port)) try - client = HT.Client(request_timeout=0.5) + client = HT.Client(request_timeout=1.0e-9) try - url = "http://127.0.0.1:$(HT.port(slow_server))/" + url = "http://$(address)/" err = try HT.get(client, url; retry=false) nothing @@ -2944,7 +2878,7 @@ end close(client) end finally - HT.forceclose(slow_server) + HTTP.@try_ignore NC.close(listener) end end diff --git a/test/http_client_transport_tests.jl b/test/http_client_transport_tests.jl index a41f782ca..09deabc90 100644 --- a/test/http_client_transport_tests.jl +++ b/test/http_client_transport_tests.jl @@ -65,173 +65,28 @@ function _deflate_bytes_transport(text::String)::Vector{UInt8} return transcode(HTTP.CodecZlib.ZlibCompressor, collect(codeunits(text))) end -function _wait_task!(task::Task; timeout_s::Float64 = 5.0) - status = timedwait(() -> istaskdone(task), timeout_s; pollint = 0.001) - status == :timed_out && error("timed out waiting for server task") +function _wait_task!(task::Task) fetch(task) return nothing end -function _transport_debug(msg::AbstractString) - _ = msg - return nothing -end - -const _HTTP_WINDOWS_TRANSPORT_WARMED = Ref(false) -const _HTTP_WINDOWS_TRANSPORT_HOSTRESOLVER_WARMED = Ref(false) - -function _transport_windows_ci_warmup!()::Nothing - _http_windows_ci() || return nothing - _HTTP_WINDOWS_TRANSPORT_WARMED[] && return nothing - _HTTP_WINDOWS_TRANSPORT_WARMED[] = true - - listener = nothing - transport = nothing - server_task = nothing +function _wait_for_transport_waiter!(transport::HT.Transport, key::String)::Nothing + lock(transport.lock) try - listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 4) - laddr = NC.addr(listener)::NC.SocketAddrV4 - address = ND.join_host_port("127.0.0.1", Int(laddr.port)) - server_task = errormonitor(Threads.@spawn begin - conn = NC.accept(listener) - try - for _ in 1:2 - request = HT.read_request(HT._ConnReader(conn)) - _read_all_transport_body_bytes(request.body) - _write_response_to_conn!(conn, request; body_text = "warmup") - end - finally - HTTP.@try_ignore NC.close(conn) - end - return nothing - end) - transport = HT.Transport(max_idle_per_host = 2, max_idle_total = 2) - for target in ("/warmup-one", "/warmup-two") - req = HT.Request("GET", target; host = address, body = HT.EmptyBody(), content_length = 0) - HT.set_deadline!(HT.get_request_context(req), Int64(time_ns()) + 2_000_000_000) - resp = HT.roundtrip!(transport, address, req) - _read_all_transport_body_bytes(resp.body) + while isempty(get(() -> HT._ConnWaiter[], transport.waiters, key)) + wait(transport.waiter_condition) end - HTTP.@try_ignore begin - _wait_task!(server_task; timeout_s = 2.0) - end - catch - # The warmup is best-effort: it exists only to exercise the flaky - # first-pass Windows CI compiler/runtime path before the real tests. finally - server_task === nothing || HTTP.@try_ignore begin - _wait_task!(server_task; timeout_s = 0.5) - end - transport === nothing || close(transport) - if listener !== nothing - HTTP.@try_ignore NC.close(listener) - end - GC.gc() - yield() - IP.shutdown!() + unlock(transport.lock) end return nothing end -function _transport_windows_hostresolver_warmup!()::Nothing - _http_windows_ci() || return nothing - _HTTP_WINDOWS_TRANSPORT_HOSTRESOLVER_WARMED[] && return nothing - _HTTP_WINDOWS_TRANSPORT_HOSTRESOLVER_WARMED[] = true - - listener = nothing - transport = nothing - server_task = nothing - try - listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 8) - laddr = NC.addr(listener)::NC.SocketAddrV4 - address = ND.join_host_port("127.0.0.1", Int(laddr.port)) - server_task = errormonitor(Threads.@spawn begin - for _ in 1:2 - conn = NC.accept(listener) - try - request = HT.read_request(HT._ConnReader(conn)) - _read_all_transport_body_bytes(request.body) - _write_response_to_conn!(conn, request; body_text = "warmup", close_conn = true) - finally - HTTP.@try_ignore NC.close(conn) - end - end - return nothing - end) - transport = HT.Transport(max_idle_per_host = 4, max_idle_total = 4) - req1 = HT.Request("GET", "/one"; host = address, body = HT.EmptyBody(), content_length = 0) - req2 = HT.Request("GET", "/two"; host = address, body = HT.EmptyBody(), content_length = 0) - deadline_ns = Int64(time_ns()) + 3_000_000_000 - HT.set_deadline!(HT.get_request_context(req1), deadline_ns) - HT.set_deadline!(HT.get_request_context(req2), deadline_ns) - task1 = errormonitor(Threads.@spawn HT.roundtrip!(transport, address, req1)) - task2 = errormonitor(Threads.@spawn HT.roundtrip!(transport, address, req2)) - HTTP.@try_ignore begin - _wait_task!(task1; timeout_s = 2.0) - _wait_task!(task2; timeout_s = 2.0) - _read_all_transport_body_bytes(fetch(task1).body) - _read_all_transport_body_bytes(fetch(task2).body) - end - HTTP.@try_ignore begin - _wait_task!(server_task; timeout_s = 2.0) - end - catch - # The warmup is best-effort: it exists only to exercise the flaky - # Windows CI resolver/compiler path before the real tests. - finally - transport === nothing || close(transport) - if listener !== nothing - HTTP.@try_ignore NC.close(listener) - end - server_task === nothing || HTTP.@try_ignore begin - _wait_task!(server_task; timeout_s = 0.5) - end - GC.gc() - yield() - IP.shutdown!() - end +function _transport_debug(msg::AbstractString) + _ = msg return nothing end -mutable struct _CountingResolverTransport <: ND.AbstractResolver - delay_s::Float64 - addrs::Vector{NC.SocketEndpoint} - lock::ReentrantLock - calls::Int -end - -function _CountingResolverTransport(delay_s::Float64, addrs::Vector{NC.SocketEndpoint}) - return _CountingResolverTransport(delay_s, addrs, ReentrantLock(), 0) -end - -function ND.resolve_tcp_addrs( - resolver::_CountingResolverTransport, - network::AbstractString, - address::AbstractString; - op::Symbol = :connect, - policy::ND.ResolverPolicy = ND.ResolverPolicy(), - )::Vector{NC.SocketEndpoint} - _ = network - _ = address - _ = op - _ = policy - lock(resolver.lock) - try - resolver.calls += 1 - finally - unlock(resolver.lock) - end - sleep(resolver.delay_s) - return copy(resolver.addrs) -end - -if _http_windows_ci() - @testset "HTTP client transport windows warmup" begin - _transport_windows_ci_warmup!() - _transport_windows_hostresolver_warmup!() - end -end - @testset "_read_all_response_bytes caps eager preallocation" begin payload = collect(codeunits("ok")) body = HT.BytesBody(payload) @@ -243,6 +98,31 @@ end @test_throws ArgumentError HT.Transport(max_conns_per_host = -1) end +@testset "HTTP transport waiter uses deterministic wake state" begin + transport = HT.Transport() + waiter = HT._ConnWaiter("http://waiter.test") + wait_calls = Ref(0) + wait_for = (predicate, _timeout_seconds; kwargs...) -> begin + wait_calls[] += 1 + @atomic :release waiter.state = HT._CONN_WAITER_DIAL + @test predicate() + return :ok + end + try + result = HT._wait_for_conn!( + transport, + waiter, + Int64(10); + clock_ns = () -> Int64(0), + wait_for = wait_for, + ) + @test result === :dial + @test wait_calls[] == 1 + finally + close(transport) + end +end + @testset "HTTP client transport handles duplicate concurrent requests" begin listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 8) laddr = NC.addr(listener)::NC.SocketAddrV4 @@ -264,9 +144,6 @@ end try req1 = HT.Request("GET", "/one"; host = address, body = HT.EmptyBody(), content_length = 0) req2 = HT.Request("GET", "/two"; host = address, body = HT.EmptyBody(), content_length = 0) - deadline_ns = Int64(time_ns()) + 3_000_000_000 - HT.set_deadline!(HT.get_request_context(req1), deadline_ns) - HT.set_deadline!(HT.get_request_context(req2), deadline_ns) task1 = errormonitor(Threads.@spawn HT.roundtrip!(transport, address, req1)) task2 = errormonitor(Threads.@spawn HT.roundtrip!(transport, address, req2)) @test _wait_task!(task1) === nothing @@ -312,6 +189,24 @@ end end end +@testset "_ConnReader reads a byte through the refill path" begin + listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 1) + laddr = NC.addr(listener)::NC.SocketAddrV4 + address = ND.join_host_port("127.0.0.1", Int(laddr.port)) + client = nothing + conn = nothing + try + client = ND.connect("tcp", address) + conn = NC.accept(listener) + write(client, UInt8('x')) + @test HT._read_u8(HT._ConnReader(conn, 1)) == UInt8('x') + finally + client === nothing || HTTP.@try_ignore NC.close(client) + conn === nothing || HTTP.@try_ignore NC.close(conn) + HTTP.@try_ignore NC.close(listener) + end +end + if _http_windows_ci() @testset "HTTP client transport keep-alive reuse" begin @test_skip true @@ -584,7 +479,8 @@ end req2 = HT.Request("GET", "/two"; host = address, body = HT.EmptyBody(), content_length = 0) res2_task = errormonitor(Threads.@spawn HT.roundtrip!(transport, address, req2)) - @test timedwait(() -> istaskdone(res2_task), 0.05; pollint = 0.001) == :timed_out + + _wait_for_transport_waiter!(transport, "http://$address") @test String(_read_all_transport_body_bytes(res1.body)) == "first" @@ -615,7 +511,6 @@ end req1 = HT.read_request(HT._ConnReader(conn1)) _read_all_transport_body_bytes(req1.body) _write_response_bytes_to_conn!(conn1, req1; body_bytes = first_body) - NC.set_read_deadline!(conn1, Int64(time_ns()) + 300_000_000) try req_maybe = HT.read_request(HT._ConnReader(conn1)) same_conn_second_request[] = true @@ -648,7 +543,8 @@ end req2 = HT.Request("GET", "/two"; host = address, body = HT.EmptyBody(), content_length = 0) res2_task = errormonitor(Threads.@spawn HT.roundtrip!(transport, address, req2)) - @test timedwait(() -> istaskdone(res2_task), 0.05; pollint = 0.001) == :timed_out + + _wait_for_transport_waiter!(transport, "http://$address") first_byte = Vector{UInt8}(undef, 1) @test HT.body_read!(res1.body, first_byte) == 1 @@ -680,7 +576,6 @@ end req1 = HT.read_request(HT._ConnReader(conn)) _read_all_transport_body_bytes(req1.body) _write_response_to_conn!(conn, req1; body_text = "first") - NC.set_read_deadline!(conn, Int64(time_ns()) + 300_000_000) try req2 = HT.read_request(HT._ConnReader(conn)) second_request_seen[] = true @@ -702,7 +597,7 @@ end @test res1.status == 200 req2 = HT.Request("GET", "/two"; host = address, body = HT.EmptyBody(), content_length = 0) - HT.set_deadline!(HT.get_request_context(req2), Int64(time_ns()) + 50_000_000) + HT.set_deadline!(HT.get_request_context(req2), Int64(1)) err = try HT.roundtrip!(transport, address, req2) nothing @@ -755,25 +650,16 @@ end listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 8) laddr = NC.addr(listener)::NC.SocketAddrV4 address = ND.join_host_port("127.0.0.1", Int(laddr.port)) - sent_body_before_continue = Ref(false) + body_requested = Channel{Nothing}(1) server_task = errormonitor(Threads.@spawn begin conn = NC.accept(listener) reader = HT._ConnReader(conn) try request = HT.read_request(reader) - probe = Vector{UInt8}(undef, 1) - NC.set_read_deadline!(conn, Int64(time_ns()) + 100_000_000) - try - sent_body_before_continue[] = HT.body_read!(request.body, probe) > 0 - catch err - if !(err isa IP.DeadlineExceededError || err isa HT.ParseError || err isa HT.ProtocolError) - rethrow(err) - end - finally - NC.set_read_deadline!(conn, Int64(0)) - end + @test !isready(body_requested) _write_all_tcp!(conn, collect(codeunits("HTTP/1.1 100 Continue\r\n\r\n"))) @test String(_read_all_transport_body_bytes(request.body)) == "hello" + take!(body_requested) _write_response_to_conn!(conn, request; body_text = "done", close_conn = true) finally HTTP.@try_ignore NC.close(conn) @@ -784,12 +670,23 @@ end try headers = HT.Headers() HT.setheader(headers, "Expect", "100-continue") - req = HT.Request("POST", "/continue"; host = address, headers = headers, body = HT.BytesBody(collect(codeunits("hello"))), content_length = 5) + body_sent = Ref(false) + body = HT.CallbackBody( + dst -> begin + body_sent[] && return 0 + put!(body_requested, nothing) + bytes = collect(codeunits("hello")) + copyto!(dst, 1, bytes, 1, length(bytes)) + body_sent[] = true + return length(bytes) + end, + () -> nothing, + ) + req = HT.Request("POST", "/continue"; host = address, headers = headers, body = body, content_length = 5) res = HT.roundtrip!(transport, address, req) @test res.status == 200 @test String(_read_all_transport_body_bytes(res.body)) == "done" _wait_task!(server_task) - @test !sent_body_before_continue[] finally close(transport) HTTP.@try_ignore NC.close(listener) @@ -801,6 +698,7 @@ end laddr = NC.addr(listener)::NC.SocketAddrV4 address = ND.join_host_port("127.0.0.1", Int(laddr.port)) close_count = Ref(0) + body_closed = Base.Event() stage = Ref(1) second_chunk_started = Channel{Nothing}(1) release_second_chunk = Channel{Nothing}(1) @@ -824,6 +722,7 @@ end end, () -> begin close_count[] += 1 + notify(body_closed) return nothing end, ) @@ -831,6 +730,7 @@ end conn = NC.accept(listener) try request = HT.read_request(HT._ConnReader(conn)) + take!(second_chunk_started) _write_response_bytes_to_conn!(conn, request; body_bytes = collect(codeunits("early")), close_conn = true) finally HTTP.@try_ignore NC.close(conn) @@ -841,22 +741,12 @@ end try req = HT.Request("POST", "/early"; host = address, body = callback_body, content_length = 10) res_task = errormonitor(Threads.@spawn HT.roundtrip!(transport, address, req)) - ready = timedwait(() -> istaskdone(res_task) || isready(second_chunk_started), 2.0; pollint = 0.001) - @test ready == :ok - if ready == :ok && isready(second_chunk_started) && !istaskdone(res_task) - response_ready = timedwait(() -> istaskdone(res_task), 2.0; pollint = 0.001) - @test response_ready == :ok - end - @test istaskdone(res_task) + res = fetch(res_task) isready(release_second_chunk) || put!(release_second_chunk, nothing) - if istaskdone(res_task) - res = fetch(res_task) - @test res.status == 200 - @test String(_read_all_transport_body_bytes(res.body)) == "early" - @test timedwait(() -> close_count[] == 1, 2.0; pollint = 0.001) != :timed_out - else - _wait_task!(res_task) - end + wait(body_closed) + @test res.status == 200 + @test String(_read_all_transport_body_bytes(res.body)) == "early" + @test close_count[] == 1 _wait_task!(server_task) finally isready(release_second_chunk) || put!(release_second_chunk, nothing) @@ -904,7 +794,7 @@ end @test res.status == 200 @test String(_read_all_transport_body_bytes(res.body)) == "done" _wait_task!(server_task) - @test timedwait(() -> close_count[] == 1, 2.0; pollint = 0.001) != :timed_out + @test close_count[] == 1 finally close(transport) HTTP.@try_ignore NC.close(listener) @@ -927,7 +817,6 @@ end push!(paths, req1.target) _read_all_transport_body_bytes(req1.body) _write_response_bytes_to_conn!(conn, req1; body_bytes = first_body) - NC.set_read_deadline!(conn, Int64(time_ns()) + 300_000_000) try req2 = HT.read_request(HT._ConnReader(conn)) same_conn_second_request[] = true @@ -988,8 +877,8 @@ end request = HT.read_request(HT._ConnReader(conn)) _read_all_transport_body_bytes(request.body) _write_response_to_conn!(conn, request; body_text = "ok") - NC.set_read_deadline!(conn, Int64(time_ns()) + 100_000_000) - HTTP.@try_ignore _ = HT.read_request(HT._ConnReader(conn)) + probe = Vector{UInt8}(undef, 1) + @test readbytes!(conn, probe, 1; all = true) == 0 finally HTTP.@try_ignore NC.close(conn) end @@ -1029,7 +918,6 @@ end req1 = HT.read_request(HT._ConnReader(conn1)) _read_all_transport_body_bytes(req1.body) _write_response_bytes_to_conn!(conn1, req1; body_bytes = first_body) - NC.set_read_deadline!(conn1, Int64(time_ns()) + 300_000_000) try req_maybe = HT.read_request(HT._ConnReader(conn1)) same_conn_second_request[] = true @@ -1084,6 +972,7 @@ end methods = String[] paths = String[] bodies = String[] + first_conn_closed = Channel{Nothing}(1) server_task = errormonitor(Threads.@spawn begin conn1 = NC.accept(listener) accept_count[] += 1 @@ -1093,9 +982,9 @@ end push!(paths, req1.target) push!(bodies, String(_read_all_transport_body_bytes(req1.body))) _write_response_to_conn!(conn1, req1; body_text = "warmup") - sleep(0.15) finally HTTP.@try_ignore NC.close(conn1) + put!(first_conn_closed, nothing) end conn2 = NC.accept(listener) accept_count[] += 1 @@ -1115,7 +1004,7 @@ end req1 = HT.Request("GET", "/warmup"; host = address, body = HT.EmptyBody(), content_length = 0) res1 = HT.roundtrip!(transport, address, req1) @test String(_read_all_transport_body_bytes(res1.body)) == "warmup" - sleep(0.20) + take!(first_conn_closed) req2 = HT.Request( "QUERY", "/retry"; @@ -1154,7 +1043,7 @@ end HTTP.get(url) client = HTTP._DEFAULT_CLIENT[] @test client !== nothing - @test timedwait(() -> HT.idle_connection_count(client.transport) >= 1, 5.0) === :ok + @test HT.idle_connection_count(client.transport) >= 1 # No-arg form closes the default client's idle connections. @test HTTP.close_idle_connections!() === nothing @test HT.idle_connection_count(client.transport) == 0 diff --git a/test/http_cookie_tests.jl b/test/http_cookie_tests.jl index 0ea2b0572..61d208393 100644 --- a/test/http_cookie_tests.jl +++ b/test/http_cookie_tests.jl @@ -280,7 +280,7 @@ end stale = HT.Cookie("stale", "1"; domain = "example.com", path = "/") stale.persistent = true - stale.expires = HT.Cookies.Dates.now(HT.Cookies.Dates.UTC) - HT.Cookies.Dates.Second(1) + stale.expires = HT.Cookies.Dates.DateTime(2000, 1, 1) stale.creation = old_creation stale.lastaccess = old_creation Base.@lock jar.lock begin diff --git a/test/http_core_tests.jl b/test/http_core_tests.jl index 724b50267..e643dccdd 100644 --- a/test/http_core_tests.jl +++ b/test/http_core_tests.jl @@ -164,7 +164,7 @@ end @test get(() -> "fallback", ctx, :missing) == "fallback" empty!(ctx) @test !haskey(ctx, :route) - HT.set_deadline!(ctx, time_ns() + 50_000_000) + HT.set_deadline!(ctx, typemax(Int64)) @test !HT.expired(ctx) HT.set_deadline!(ctx, 1) @test HT.expired(ctx) @@ -313,8 +313,17 @@ end @test HT.ConnectError(addr, cause).address == addr @test HT.ConnectError(addr, cause).cause === cause @test HT.DNSError("host.invalid", cause).hostname == "host.invalid" + @test HT.TLSHandshakeError(cause).cause === cause @test HT.AddressInUseError(addr).address == addr + tls_error = Reseau.TLS.TLSError("handshake", Int32(-1), "boom", nothing) + wrapped_tls_error = HT._wrap_client_transport_error(tls_error) + @test wrapped_tls_error isa HT.TLSHandshakeError + @test (wrapped_tls_error::HT.TLSHandshakeError).cause === tls_error + + bytes = UInt8[0x41] + @test HT._response_body_arg(bytes) === bytes + # TimeoutError carries operation, timeout_ns, and elapsed_ns. err = HT.TimeoutError("connect", Int64(2_000_000_000), Int64(1_999_000_000)) @test err.operation == "connect" diff --git a/test/http_handlers_tests.jl b/test/http_handlers_tests.jl index b6a9396d9..8484d1ce2 100644 --- a/test/http_handlers_tests.jl +++ b/test/http_handlers_tests.jl @@ -157,15 +157,20 @@ end expected_pool = Threads.nthreads(:interactive) > 0 ? :interactive : :default @test take!(handler_pool) == expected_pool - slow = HT.Handlers.handlertimeout(0.02; status = 504, body = "custom timeout")(req -> begin - _ = req - sleep(0.1) + slow_started = Channel{HT.RequestContext}(1) + release_slow = Channel{Nothing}(1) + slow = HT.Handlers.handlertimeout(1.0e-9; status = 504, body = "custom timeout")(req -> begin + put!(slow_started, HT.get_request_context(req)) + take!(release_slow) return _response_with_text("late") end) slow_resp = slow(HT.Request("GET", "/")) @test slow_resp.status == 504 @test HT.header(slow_resp.headers, "Content-Type") == "text/plain; charset=utf-8" @test String(_read_all_handler_bytes(slow_resp.body)) == "custom timeout" + slow_context = take!(slow_started) + @test HT.canceled(slow_context) + put!(release_slow, nothing) end @testset "HTTP streamhandler helper" begin diff --git a/test/http_integration_tests.jl b/test/http_integration_tests.jl index 763d51c20..3af3286d6 100644 --- a/test/http_integration_tests.jl +++ b/test/http_integration_tests.jl @@ -211,6 +211,7 @@ end laddr = NC.addr(listener)::NC.SocketAddrV4 address = ND.join_host_port("127.0.0.1", Int(laddr.port)) accepted = Channel{Nothing}(8) + release = Channel{Nothing}(2) server_task = errormonitor(Threads.@spawn begin workers = Task[] try @@ -232,7 +233,7 @@ end for header in decoded header.name == ":path" && (path = header.value) end - sleep(1.0) + take!(release) encoded = HT.encode_header_block(encoder, HT.HeaderField[HT.HeaderField(":status", "200", false)]) _write_frame_h2_integration!(conn, HT.HeadersFrame(headers_frame.stream_id, false, true, encoded)) _write_frame_h2_integration!(conn, HT.DataFrame(headers_frame.stream_id, true, collect(codeunits("resp:" * path)))) @@ -252,28 +253,22 @@ end return nothing end) client = HT.Client(transport = HT.Transport(max_idle_per_host = 4, max_idle_total = 4), prefer_http2 = true) - started = time() try t1 = errormonitor(Threads.@spawn HT.get!(client, address, "/one"; protocol = :h2)) t2 = errormonitor(Threads.@spawn HT.get!(client, address, "/two"; protocol = :h2)) - @test timedwait(() -> istaskdone(t1) && istaskdone(t2), 4.0; pollint = 0.001) != :timed_out + take!(accepted) + take!(accepted) + put!(release, nothing) + put!(release, nothing) r1 = fetch(t1) r2 = fetch(t2) - elapsed = time() - started @test r1.status == 200 @test r2.status == 200 @test String(_read_all_integration(r1.body)) == "resp:/one" @test String(_read_all_integration(r2.body)) == "resp:/two" - @test elapsed < 1.8 finally close(client) HTTP.@try_ignore NC.close(listener) fetch(server_task) end - accepted_count = 0 - while isready(accepted) - take!(accepted) - accepted_count += 1 - end - @test accepted_count >= 2 end diff --git a/test/http_parity_tests.jl b/test/http_parity_tests.jl index fb6d045d0..c6f0c92ad 100644 --- a/test/http_parity_tests.jl +++ b/test/http_parity_tests.jl @@ -17,9 +17,7 @@ function _read_all_parity(body::HT.AbstractBody)::Vector{UInt8} return out end -function _wait_task_parity!(task::Task; timeout_s::Float64 = 5.0) - status = timedwait(() -> istaskdone(task), timeout_s; pollint = 0.001) - status == :timed_out && error("timed out waiting for task") +function _wait_task_parity!(task::Task) fetch(task) return nothing end diff --git a/test/http_retry_tests.jl b/test/http_retry_tests.jl index aac5aa958..8582efedf 100644 --- a/test/http_retry_tests.jl +++ b/test/http_retry_tests.jl @@ -52,9 +52,7 @@ function _send_response_retry!( return nothing end -function _wait_task_retry!(task::Task; timeout_s::Float64 = 5.0) - status = timedwait(() -> istaskdone(task), timeout_s; pollint = 0.001) - status == :timed_out && error("timed out waiting for retry test task") +function _wait_task_retry!(task::Task) fetch(task) return nothing end @@ -512,36 +510,20 @@ end end @testset "retry arming refunds the bucket when the deadline preempts the backoff" begin - listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 8) - address = ND.join_host_port("127.0.0.1", Int((NC.addr(listener)::NC.SocketAddrV4).port)) - base_url = "http://$(address)" - seen = Tuple{String, String, String}[] - server_task = _serve_retry_sequence(listener, [ - (status = 503, reason = "Service Unavailable", retry_after = "60"), - ], seen) - try - # A fixed backoff far beyond the request deadline means the armed retry - # is abandoned before it sleeps. The bucket reservation must be - # refunded rather than the release erroring (it used to be called with - # `nothing` as the failure cost, which has no method). - bucket = HT.RetryBucket(capacity = 15, backoff_scale_factor_ms = 60_000, max_backoff_secs = 60) - response = HT.get( - "$(base_url)/deadline"; - retries = 2, - retry_bucket = bucket, - request_timeout = 10, - status_exception = false, - ) - @test response.status == 503 - _wait_task_retry!(server_task) - @test seen == [("GET", "/deadline", "")] - # Full refund: with capacity 15 a second reservation (cost 10) only - # succeeds if the abandoned one returned its 10. - token = Base.acquire(bucket, only(keys(bucket.partitions))) - Base.release(bucket, token, 0) - finally - HTTP.@try_ignore NC.close(listener) - end + bucket = HT.RetryBucket(capacity=15, backoff_scale_factor_ms=60_000, max_backoff_secs=60) + controller = HT._RetryController(true, 2, false, nothing, true, bucket) + request = HT.Request("GET", "/deadline"; host="example.com", context=HT.RequestContext(deadline_ns=1)) + response = HT.Response(503; headers=["Retry-After" => "60"]) + + armed, token, delay_ns = HT._arm_request_retry!(controller, "example.com:80", request, 1, response) + @test !armed + @test token === nothing + @test delay_ns == 60_000_000_000 + + # Full refund: with capacity 15 a second reservation (cost 10) only + # succeeds if the abandoned one returned its 10. + refunded = Base.acquire(bucket, only(keys(bucket.partitions))) + Base.release(bucket, refunded, 0) end @testset "HTTP.open retries idempotent buffered requests" begin @@ -598,11 +580,10 @@ end headers = HT.Headers(["Retry-After" => "0"]) @test HT._retry_after_delay_ns(headers) == 0 - future = HTTP.Dates.now(HTTP.Dates.UTC) + HTTP.Dates.Second(1) + now = HTTP.Dates.DateTime(2026, 1, 2, 3, 4, 5) + future = now + HTTP.Dates.Second(1) headers_date = HT.Headers(["Retry-After" => HTTP.Dates.format(future, HTTP.Dates.RFC1123Format) * " GMT"]) - delay_ns = HT._retry_after_delay_ns(headers_date) - @test delay_ns !== nothing - @test delay_ns >= 0 + @test HT._parse_retry_after_delay_ns(HT.header(headers_date, "Retry-After"); now=now) == 1_000_000_000 invalid_headers = HT.Headers(["Retry-After" => "nonsense"]) @test HT._retry_after_delay_ns(invalid_headers) === nothing diff --git a/test/http_server_http1_tests.jl b/test/http_server_http1_tests.jl index a73aafca3..b1d90778f 100644 --- a/test/http_server_http1_tests.jl +++ b/test/http_server_http1_tests.jl @@ -8,18 +8,6 @@ const NC = Reseau.TCP const ND = Reseau.HostResolvers const IOP = Reseau.IOPoll -mutable struct _DefaultPoolBlockState - @atomic started::Int - @atomic stop::Bool -end - -function _block_default_pool!(state::_DefaultPoolBlockState, deadline_ns::Int64)::Nothing - @atomic state.started += 1 - while !(@atomic :acquire state.stop) && time_ns() < deadline_ns - end - return nothing -end - function _read_all_server_bytes(body::HT.AbstractBody)::Vector{UInt8} out = UInt8[] buf = Vector{UInt8}(undef, 32) @@ -31,11 +19,8 @@ function _read_all_server_bytes(body::HT.AbstractBody)::Vector{UInt8} return out end -function _run_with_timeout(f::F; timeout_s::Float64 = 5.0, label::String = "operation") where {F <: Function} - task = Threads.@spawn f() - status = timedwait(() -> istaskdone(task), timeout_s; pollint = 0.001) - status == :timed_out && error("timed out waiting for $label") - return fetch(task) +function _run_test_operation(f::F) where {F <: Function} + return f() end mutable struct _BlockingResponseBody <: HT.AbstractBody @@ -58,250 +43,72 @@ function HT.body_close!(body::_BlockingResponseBody) return nothing end -# Both raw-socket helpers bound their reads with socket read deadlines, but on -# Windows a blocked `readavailable` is not always interrupted by the deadline, -# so the loop never gets back to its own timeout check and the read hangs -# indefinitely. Wrap the whole exchange in `_run_with_timeout`, a task-level -# watchdog that does not depend on socket deadlines, so a stuck read fails the -# test in seconds instead of hanging the job until CI's wall-clock limit. -# Raised when the raw probe saw no response bytes at all within its budget. -# On Windows this is the known IOCP wake strand (JuliaServices/Reseau.jl#107): -# the parked reader is intermittently never woken even though bytes arrive. -struct _FirstByteTimeout <: Exception end - -# `_read_until_quiet` runs under its own `_run_with_timeout` watchdog, so the -# timeout surfaces here wrapped in (possibly nested) TaskFailedException. -function _is_first_byte_timeout(err)::Bool - err isa _FirstByteTimeout && return true - err isa TaskFailedException || return false - inner = err.task.exception - return inner !== nothing && _is_first_byte_timeout(inner) +function _read_exact_server!(conn::NC.Conn, n::Int)::Vector{UInt8} + bytes = Vector{UInt8}(undef, n) + readbytes!(conn, bytes, n; all = true) == n || error("unexpected EOF") + return bytes +end + +function _read_until_server_marker(conn::NC.Conn, marker::AbstractString)::String + marker_bytes = Vector{UInt8}(codeunits(String(marker))) + bytes = UInt8[] + while true + push!(bytes, only(_read_exact_server!(conn, 1))) + length(bytes) >= length(marker_bytes) || continue + @view(bytes[(end - length(marker_bytes) + 1):end]) == marker_bytes && return String(bytes) + end end function _raw_http_request( port::Integer, request::AbstractString; - settle_s::Float64 = 0.5, close_write::Bool = true, - wait_for_first_byte::Bool = true, - # Re-dial fresh and retry when no first byte arrives. Windows-only by - # default so other platforms stay strict; safe for these probes because no - # caller asserts on server-side invocation counts. - attempts::Int = Sys.iswindows() ? 3 : 1, )::String - return _run_with_timeout(; timeout_s = max(8.0, settle_s + 4.0) * attempts, label = "raw http request (port $(port))") do - for attempt in 1:attempts - sock = ND.connect("tcp", "127.0.0.1:$(Int(port))") - try - write(sock, Vector{UInt8}(codeunits(String(request)))) - if close_write - HT.@try_ignore begin - NC.closewrite(sock) - end - end - # Hosted Linux runners can delay an otherwise immediate local - # response past two seconds under load. Keep a five-second - # first-byte budget on every platform. A persistent no-response - # condition still fails, and re-dial retries remain Windows-only. - first_byte_timeout_s = max(5.0, settle_s + 1.0) - return _read_until_quiet( - sock; - timeout_s = first_byte_timeout_s, - quiet_timeout_s = min(0.25, max(0.05, settle_s)), - wait_for_first_byte = wait_for_first_byte, - ) - catch err - _is_first_byte_timeout(err) || rethrow(err) - attempt < attempts || error("timed out waiting for first response byte ($(attempts) attempts)") - @warn "raw probe got no first byte; re-dialing after known Windows IOCP wake strand (Reseau#107)" attempt port - finally - NC.close(sock) + sock = ND.connect("tcp", "127.0.0.1:$(Int(port))") + try + write(sock, Vector{UInt8}(codeunits(String(request)))) + if close_write + HT.@try_ignore begin + NC.closewrite(sock) end end - error("unreachable: raw probe retry loop exited") + return String(read(sock)) + finally + NC.close(sock) end end function _raw_http_request_until_close( port::Integer, request::AbstractString; - timeout_s::Float64 = 3.0, - wait_for_first_byte::Bool = true, - attempts::Int = Sys.iswindows() ? 3 : 1, )::Tuple{String, Bool} - return _run_with_timeout(; timeout_s = (timeout_s + 4.0) * attempts, label = "raw http request until close (port $(port))") do - for attempt in 1:attempts - sock = ND.connect("tcp", "127.0.0.1:$(Int(port))") - try - write(sock, Vector{UInt8}(codeunits(String(request)))) - return _read_until_close(sock; timeout_s, wait_for_first_byte) - catch err - _is_first_byte_timeout(err) || rethrow(err) - attempt < attempts || error("timed out waiting for first response byte before close ($(attempts) attempts)") - @warn "raw probe got no first byte before close; re-dialing after known Windows IOCP wake strand (Reseau#107)" attempt port - finally - NC.close(sock) - end - end - error("unreachable: raw probe retry loop exited") - end -end - -function _read_until_deadline(conn::NC.Conn; timeout_s::Float64 = 1.0)::String - buf = Vector{UInt8}(undef, 1024) - out = UInt8[] - while true - NC.set_read_deadline!(conn, Int64(time_ns()) + round(Int64, timeout_s * 1.0e9)) - try - chunk = readavailable(conn) - n = length(chunk) - n == 0 && break - n > length(buf) && resize!(buf, n) - copyto!(buf, 1, chunk, 1, n) - append!(out, @view(buf[1:n])) - catch err - if err isa IOP.DeadlineExceededError || err isa EOFError - break - end - if HT._is_peer_close_error(err::Exception) - break - end - rethrow(err) - end - end - return String(out) -end - -function _read_until_quiet( - conn::NC.Conn; - timeout_s::Float64 = 1.0, - quiet_timeout_s::Float64 = 0.1, - wait_for_first_byte::Bool = false, -)::String - # Task-level watchdog. The set_read_deadline! below is the intended timeout - # mechanism, but on Windows a re-armed read deadline can intermittently fail - # to wake a parked `readavailable`, stranding this loop indefinitely (Reseau - # IOCP issue JuliaServices/Reseau.jl#107). Bounding the whole read here turns - # a strand into a fast, legible failure instead of hanging the suite, and - # covers callers that invoke `_read_until_quiet` directly (not just via - # `_raw_http_request`). - return _run_with_timeout(; timeout_s = timeout_s + 5.0, label = "_read_until_quiet") do - buf = Vector{UInt8}(undef, 1024) - out = UInt8[] - deadline_ns = Int64(time_ns()) + round(Int64, timeout_s * 1.0e9) - saw_bytes = false - while true - remaining_ns = deadline_ns - Int64(time_ns()) - remaining_ns <= 0 && break - read_timeout_s = saw_bytes ? min(quiet_timeout_s, remaining_ns / 1.0e9) : (remaining_ns / 1.0e9) - NC.set_read_deadline!(conn, Int64(time_ns()) + round(Int64, read_timeout_s * 1.0e9)) - try - chunk = readavailable(conn) - n = length(chunk) - if n == 0 - # Some transports can report an empty read before the server - # has produced the response bytes this probe is asserting on. - (saw_bytes || !wait_for_first_byte) && break - yield() - continue - end - n > length(buf) && resize!(buf, n) - copyto!(buf, 1, chunk, 1, n) - append!(out, @view(buf[1:n])) - saw_bytes = true - catch err - if err isa IOP.DeadlineExceededError || err isa EOFError - break - end - if HT._is_peer_close_error(err::Exception) - break - end - rethrow(err) - end - end - if wait_for_first_byte && !saw_bytes - throw(_FirstByteTimeout()) - end - return String(out) - end -end - -function _read_until_close(conn::NC.Conn; timeout_s::Float64 = 1.0, wait_for_first_byte::Bool = false)::Tuple{String, Bool} - buf = Vector{UInt8}(undef, 1024) - out = UInt8[] - deadline_ns = Int64(time_ns()) + round(Int64, timeout_s * 1.0e9) - saw_bytes = false - while true - remaining_ns = deadline_ns - Int64(time_ns()) - if remaining_ns <= 0 - wait_for_first_byte && !saw_bytes && throw(_FirstByteTimeout()) - return String(out), false - end - NC.set_read_deadline!(conn, Int64(time_ns()) + remaining_ns) - try - chunk = readavailable(conn) - n = length(chunk) - if n == 0 - wait_for_first_byte && !saw_bytes && throw(_FirstByteTimeout()) - return String(out), true - end - n > length(buf) && resize!(buf, n) - copyto!(buf, 1, chunk, 1, n) - append!(out, @view(buf[1:n])) - saw_bytes = true - catch err - if err isa IOP.DeadlineExceededError - wait_for_first_byte && !saw_bytes && throw(_FirstByteTimeout()) - return String(out), false - end - (err isa EOFError || HT._is_peer_close_error(err::Exception)) && return String(out), true - rethrow(err) - end + sock = ND.connect("tcp", "127.0.0.1:$(Int(port))") + try + write(sock, Vector{UInt8}(codeunits(String(request)))) + return String(read(sock)), true + finally + NC.close(sock) end end -@testset "HTTP server remains responsive on the interactive pool" begin +@testset "HTTP server runs handlers on the interactive pool" begin if Threads.nthreads(:interactive) == 0 @test true else - state = _DefaultPoolBlockState(0, false) - blockers = Task[] server = HT.serve!("127.0.0.1", 0; listenany = true) do request _ = request - @atomic :release state.stop = true return HT.Response(200, string(Threads.threadpool())) end address = HT.server_addr(server) try - warm = HT.get( - "http://$(address)/warm"; - headers = ["Connection" => "close"], - proxy = HT.ProxyConfig(), - ) - @test String(warm.body) == "interactive" - @atomic :release state.stop = false - worker_count = Threads.nthreads(:default) - deadline_ns = Int64(time_ns()) + Int64(4_000_000_000) - for _ in 1:worker_count - push!(blockers, errormonitor(Threads.@spawn :default _block_default_pool!(state, deadline_ns))) - end - started = timedwait( - () -> (@atomic :acquire state.started) == worker_count, - 2.0; - pollint=0.001, - ) - @test started == :ok - elapsed = @elapsed response = HT.get( + response = HT.get( "http://$(address)/health"; headers = ["Connection" => "close"], proxy = HT.ProxyConfig(), ) @test response.status == 200 @test String(response.body) == "interactive" - @test elapsed < 2.0 finally - @atomic :release state.stop = true - wait.(blockers) HT.forceclose(server) wait(server) end @@ -350,8 +157,8 @@ end expected_pool = Threads.nthreads(:interactive) > 0 ? :interactive : :default @test take!(producer_pool) == expected_pool finally - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) @test !isopen(server) end end @@ -367,8 +174,8 @@ end _ = request return HT.sse_stream(200) do stream write(stream, HT.SSEEvent("one")) - delivered = timedwait(() -> isready(saw_first), 5.0; pollint = 0.01) - write(stream, HT.SSEEvent(delivered === :ok ? "two" : "first event was not delivered before close")) + take!(saw_first) + write(stream, HT.SSEEvent("two")) end end address = HT.server_addr(server) @@ -381,8 +188,8 @@ end @test response.status == 200 @test events == ["one", "two"] finally - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end @@ -431,6 +238,9 @@ end end @testset "HTTP server top-level wrapper kwargs and stream abort state" begin + @test HT._resolve_server_timeout_ns("read_timeout_ns", 0, "read_timeout", 1.25) == 1_250_000_000 + @test_throws ArgumentError HT._resolve_server_timeout_ns("read_timeout_ns", 1, "read_timeout", 1.25) + aborted_states = Channel{Bool}(2) server = HT.listen!("127.0.0.1", 0; listenany = true, @@ -463,8 +273,8 @@ end @test take!(aborted_states) == false @test take!(aborted_states) == true finally - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end @@ -480,20 +290,20 @@ end conn = ND.connect("tcp", address) try write(conn, Vector{UInt8}(codeunits("GET / HTTP/1.1\r\nHost: $(address)\r\n\r\n"))) - head = _read_until_quiet(conn; timeout_s = 2.0, quiet_timeout_s = 0.05) + head = _read_until_server_marker(conn, "\r\n\r\n") @test occursin("HTTP/1.1 200", head) @test occursin("\r\n\r\n", head) @test !occursin("hello", head) put!(gate, nothing) - rest = _read_until_quiet(conn; timeout_s = 2.0, quiet_timeout_s = 0.05) + rest = String(_read_exact_server!(conn, length(payload))) @test occursin("hello", head * rest) finally HT.@try_ignore begin NC.close(conn) end - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end @@ -518,8 +328,8 @@ end @test seen_targets == ["/one", "/two"] finally close(client.transport) - _run_with_timeout(() -> close(server); label = "server close") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> close(server)) + _run_test_operation(() -> wait(server)) @test !isopen(server) end end @@ -535,8 +345,8 @@ end @test response.status == 404 @test String(response.body) == "Not found" finally - _run_with_timeout(() -> close(server); label = "server close") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> close(server)) + _run_test_operation(() -> wait(server)) end end @@ -569,8 +379,8 @@ end read(client_stream) end finally - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end # A server stream with no live connection (e.g. one built from a buffered @@ -611,8 +421,8 @@ end @test addr.port > 0 finally HT.@try_ignore close(client) - _run_with_timeout(() -> HT.forceclose(server); label = "tls server forceclose") - _run_with_timeout(() -> wait(server); label = "tls server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end @@ -681,8 +491,8 @@ end @test HT.header(head.headers, "Content-Length") == "3" @test isempty(head.body) finally - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end @@ -768,13 +578,12 @@ end traversal = _raw_http_request( HT.port(server), - "GET /%2e%2e/secret HTTP/1.1\r\nHost: $(address)\r\n\r\n"; - settle_s = 0.1, + "GET /%2e%2e/secret HTTP/1.1\r\nHost: $(address)\r\n\r\n" ) @test occursin("HTTP/1.1 400 Bad Request", traversal) finally - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end end @@ -935,33 +744,41 @@ end dotted_route = HT.get("http://$(address)/gallery.v2"; status_exception = false) @test dotted_route.status == 404 finally - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end end @testset "HTTP server request handler timeout middleware on HTTP/1.1" begin - handler = HT.Handlers.handlertimeout(0.05)(request -> begin - if request.target == "/fast" - return HT.Response(200, HT.BytesBody(UInt8[0x6f, 0x6b]); content_length = 2) - end - sleep(0.15) + slow_started = Channel{HT.RequestContext}(1) + release_slow = Channel{Nothing}(1) + slow_handler = HT.Handlers.handlertimeout(1.0e-9)(request -> begin + put!(slow_started, HT.get_request_context(request)) + take!(release_slow) return HT.Response(200, HT.BytesBody(UInt8[0x6c, 0x61, 0x74, 0x65]); content_length = 4) end) + fast_handler = HT.Handlers.handlertimeout(60.0)(request -> begin + _ = request + return HT.Response(200, HT.BytesBody(UInt8[0x6f, 0x6b]); content_length = 2) + end) + handler = request -> request.target == "/fast" ? fast_handler(request) : slow_handler(request) server = HT.serve!(handler, "127.0.0.1", 0; listenany = true) address = HT.server_addr(server) try slow = HT.get("http://$(address)/slow"; status_exception = false) @test slow.status == 503 @test String(slow.body) == "handler timed out" + slow_context = take!(slow_started) + @test HT.canceled(slow_context) + put!(release_slow, nothing) fast = HT.get("http://$(address)/fast") @test fast.status == 200 @test String(fast.body) == "ok" finally - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end @@ -990,36 +807,35 @@ end small_header_address = HT.server_addr(small_header_server) try port_num = HT.port(server) - large_header_resp, _ = _raw_http_request_until_close(HT.port(small_header_server), "GET / HTTP/1.1\r\nHost: $(small_header_address)\r\n$(repeat("Foo: Bar\r\n", 200))\r\n"; timeout_s = 5.0) + large_header_resp, _ = _raw_http_request_until_close(HT.port(small_header_server), "GET / HTTP/1.1\r\nHost: $(small_header_address)\r\n$(repeat("Foo: Bar\r\n", 200))\r\n") @test occursin("HTTP/1.1 431 Request Header Fields Too Large", large_header_resp) - invalid_resp, _ = _raw_http_request_until_close(port_num, "GET / HTP/1.1\r\n\r\n"; timeout_s = 5.0) + invalid_resp, _ = _raw_http_request_until_close(port_num, "GET / HTP/1.1\r\n\r\n") @test occursin("HTTP/1.1 400 Bad Request", invalid_resp) - no_target_resp, _ = _raw_http_request_until_close(port_num, "SOMEMETHOD HTTP/1.1\r\nContent-Length: 0\r\n\r\n"; timeout_s = 5.0) + no_target_resp, _ = _raw_http_request_until_close(port_num, "SOMEMETHOD HTTP/1.1\r\nContent-Length: 0\r\n\r\n") @test occursin("HTTP/1.1 400 Bad Request", no_target_resp) - missing_host_resp, _ = _raw_http_request_until_close(port_num, "GET / HTTP/1.1\r\n\r\n"; timeout_s = 5.0) + missing_host_resp, _ = _raw_http_request_until_close(port_num, "GET / HTTP/1.1\r\n\r\n") @test occursin("HTTP/1.1 400 Bad Request", missing_host_resp) - whitespace_host_resp, _ = _raw_http_request_until_close(port_num, "GET / HTTP/1.1\r\nHost : $(address)\r\n\r\n"; timeout_s = 5.0) + whitespace_host_resp, _ = _raw_http_request_until_close(port_num, "GET / HTTP/1.1\r\nHost : $(address)\r\n\r\n") @test occursin("HTTP/1.1 400 Bad Request", whitespace_host_resp) - duplicate_host_resp, _ = _raw_http_request_until_close(port_num, "GET / HTTP/1.1\r\nHost: $(address)\r\nHost: $(address)\r\n\r\n"; timeout_s = 5.0) + duplicate_host_resp, _ = _raw_http_request_until_close(port_num, "GET / HTTP/1.1\r\nHost: $(address)\r\nHost: $(address)\r\n\r\n") @test occursin("HTTP/1.1 400 Bad Request", duplicate_host_resp) - invalid_target_resp, _ = _raw_http_request_until_close(port_num, "GET foo HTTP/1.1\r\nHost: $(address)\r\n\r\n"; timeout_s = 5.0) + invalid_target_resp, _ = _raw_http_request_until_close(port_num, "GET foo HTTP/1.1\r\nHost: $(address)\r\n\r\n") @test occursin("HTTP/1.1 400 Bad Request", invalid_target_resp) sock = ND.connect("tcp", "127.0.0.1:$(port_num)") try write(sock, Vector{UInt8}(codeunits("POST / HTTP/1.1\r\nHost: $(address)\r\nContent-Length: 15\r\nExpect: 100-continue\r\n\r\n"))) - sleep(0.1) - interim = _read_until_deadline(sock) + interim = String(_read_exact_server!(sock, sizeof("HTTP/1.1 100 Continue\r\n\r\n"))) @test interim == "HTTP/1.1 100 Continue\r\n\r\n" write(sock, Vector{UInt8}(codeunits("Body of Request"))) - sleep(0.1) - final = _read_until_deadline(sock) + NC.closewrite(sock) + final = String(read(sock)) @test occursin("HTTP/1.1 200 OK\r\n", final) @test occursin("Transfer-Encoding: chunked\r\n", final) @test occursin("Body of Request", final) @@ -1027,10 +843,10 @@ end NC.close(sock) end finally - _run_with_timeout(() -> HT.forceclose(small_header_server); label = "small header server forceclose") - _run_with_timeout(() -> wait(small_header_server); label = "small header server task completion") - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(small_header_server)) + _run_test_operation(() -> wait(small_header_server)) + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end @@ -1041,12 +857,12 @@ end end address = HT.server_addr(server) try - raw, closed = _raw_http_request_until_close(HT.port(server), "POST / HTTP/1.1\r\nHost: $(address)\r\nContent-Length: 0\r\nExpect: fancy-feature\r\nConnection: close\r\n\r\n"; timeout_s = 5.0) + raw, closed = _raw_http_request_until_close(HT.port(server), "POST / HTTP/1.1\r\nHost: $(address)\r\nContent-Length: 0\r\nExpect: fancy-feature\r\nConnection: close\r\n\r\n") @test occursin("HTTP/1.1 417", raw) @test closed finally - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end @@ -1066,23 +882,23 @@ end end address = HT.server_addr(server) try - head_raw = _raw_http_request(HT.port(server), "HEAD /head HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n"; settle_s = 0.3, wait_for_first_byte = true) + head_raw = _raw_http_request(HT.port(server), "HEAD /head HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n") @test occursin("HTTP/1.1 200 OK", head_raw) @test !occursin("oops", head_raw) @test !occursin("transfer-encoding: chunked", lowercase(head_raw)) - no_content_raw = _raw_http_request(HT.port(server), "GET /nocontent HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n"; settle_s = 0.3, wait_for_first_byte = true) + no_content_raw = _raw_http_request(HT.port(server), "GET /nocontent HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n") @test occursin("HTTP/1.1 204 No Content", no_content_raw) @test !occursin("oops", no_content_raw) @test !occursin("transfer-encoding: chunked", lowercase(no_content_raw)) - not_modified_raw = _raw_http_request(HT.port(server), "GET /notmodified HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n"; settle_s = 0.3, wait_for_first_byte = true) + not_modified_raw = _raw_http_request(HT.port(server), "GET /notmodified HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n") @test occursin("HTTP/1.1 304 Not Modified", not_modified_raw) @test !occursin("oops", not_modified_raw) @test !occursin("transfer-encoding: chunked", lowercase(not_modified_raw)) finally - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end @@ -1090,7 +906,7 @@ end timeout_server = HT.Server( address = "127.0.0.1:0", stream = true, - read_header_timeout_ns = 200_000_000, + read_header_timeout_ns = 1, handler = stream -> begin _ = stream return nothing @@ -1101,15 +917,14 @@ end try sock = ND.connect("tcp", "127.0.0.1:$(HT.port(timeout_server))") try - sleep(0.6) - timed_out = _read_until_deadline(sock; timeout_s = 1.0) + timed_out = String(read(sock)) @test occursin("HTTP/1.1 408 Request Timeout", timed_out) finally NC.close(sock) end finally - _run_with_timeout(() -> HT.forceclose(timeout_server); label = "timeout server forceclose") - _run_with_timeout(() -> wait(timeout_server); label = "timeout server task completion") + _run_test_operation(() -> HT.forceclose(timeout_server)) + _run_test_operation(() -> wait(timeout_server)) end error_server = HT.serve!("127.0.0.1", 0; listenany = true) do request @@ -1121,8 +936,8 @@ end response = HT.get("http://$(error_address)/"; retry = false, status_exception = false) @test response.status == 500 finally - _run_with_timeout(() -> HT.forceclose(error_server); label = "error server forceclose") - _run_with_timeout(() -> wait(error_server); label = "error server task completion") + _run_test_operation(() -> HT.forceclose(error_server)) + _run_test_operation(() -> wait(error_server)) end # Handler returning a non-Response should yield a clean 500, not a connection error. @@ -1135,8 +950,8 @@ end response = HT.get("http://$(bad_return_address)/"; retry = false, status_exception = false) @test response.status == 500 finally - _run_with_timeout(() -> HT.forceclose(bad_return_server); label = "bad return server forceclose") - _run_with_timeout(() -> wait(bad_return_server); label = "bad return server task completion") + _run_test_operation(() -> HT.forceclose(bad_return_server)) + _run_test_operation(() -> wait(bad_return_server)) end end @@ -1144,7 +959,7 @@ end server = HT.Server( address = "127.0.0.1:0", stream = true, - idle_timeout_ns = 200_000_000, + idle_timeout_ns = 1, handler = stream -> begin _ = HT.startread(stream) HT.setstatus(stream, 200) @@ -1157,26 +972,18 @@ end address = HT.server_addr(server) sock = ND.connect("tcp", "127.0.0.1:$(HT.port(server))") try - write(sock, Vector{UInt8}(codeunits("GET /one HTTP/1.1\r\nHost: $(address)\r\n\r\n"))) - first_timeout_s = Sys.iswindows() ? 5.0 : 2.0 - first = _read_until_quiet(sock; timeout_s = first_timeout_s, quiet_timeout_s = 0.1, wait_for_first_byte = true) - @test occursin("HTTP/1.1 200 OK", first) - sleep(1.0) - closed_after_idle = false - try - write(sock, Vector{UInt8}(codeunits("GET /two HTTP/1.1\r\nHost: $(address)\r\n\r\n"))) - second = _read_until_quiet(sock; timeout_s = 0.5, quiet_timeout_s = 0.1) - closed_after_idle = !occursin("HTTP/1.1 200 OK", second) - catch err - closed_after_idle = err isa EOFError || err isa SystemError || err isa IOP.DeadlineExceededError - end - @test closed_after_idle + request = HT.Request("GET", "/one"; host = address, body = HT.EmptyBody(), content_length = 0) + HT.write_request!(sock, request) + response = HT._read_response(HT._ConnReader(sock), request) + @test response.status == 200 + @test String(_read_all_server_bytes(response.body)) == "ok" + @test isempty(read(sock)) finally HT.@try_ignore begin NC.close(sock) end - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end @@ -1185,13 +992,13 @@ end server = HT.Server( address = "127.0.0.1:0", stream = true, - write_timeout_ns = 200_000_000, + write_timeout_ns = 1, handler = stream -> begin _ = HT.startread(stream) - HT.setstatus(stream, 200) - HT.startwrite(stream) chunk = fill(UInt8('x'), 64 * 1024) try + HT.setstatus(stream, 200) + HT.startwrite(stream) while true write(stream, chunk) end @@ -1206,17 +1013,13 @@ end sock = ND.connect("tcp", "127.0.0.1:$(HT.port(server))") try write(sock, Vector{UInt8}(codeunits("GET / HTTP/1.1\r\nHost: $(address)\r\n\r\n"))) - status = timedwait(() -> isready(timeout_seen), 5.0; pollint = 0.001) - @test status != :timed_out - # Only take! when the channel is ready: if the write-timeout did not fire - # (status timed out), take! on an empty channel would hang forever. - status == :timed_out || @test take!(timeout_seen) + @test take!(timeout_seen) finally HT.@try_ignore begin NC.close(sock) end - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end @@ -1231,7 +1034,7 @@ end end address = HT.server_addr(server) try - head_raw = _raw_http_request(HT.port(server), "HEAD /head HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n"; settle_s = 0.3) + head_raw = _raw_http_request(HT.port(server), "HEAD /head HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n") @test occursin("HTTP/1.1 200 OK", head_raw) @test occursin("Content-Length: 4\r\n", head_raw) @test !occursin("transfer-encoding: chunked", lowercase(head_raw)) @@ -1239,8 +1042,8 @@ end @test length(parts) == 2 @test parts[2] == "" finally - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end @@ -1260,7 +1063,7 @@ end "/nocontent" => "HTTP/1.1 204 No Content", "/notmodified" => "HTTP/1.1 304 Not Modified", ) - raw = _raw_http_request(HT.port(server), "GET $(target) HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n"; settle_s = 0.3) + raw = _raw_http_request(HT.port(server), "GET $(target) HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n") lower_raw = lowercase(raw) @test occursin(status_line, raw) @test !occursin("content-length", lower_raw) @@ -1271,8 +1074,8 @@ end @test parts[2] == "" end finally - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end @@ -1318,8 +1121,8 @@ end @test take!(seen_buffered) finally close(client) - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end @@ -1347,14 +1150,13 @@ end "Host: $(address)\r\n" * "Transfer-Encoding: chunked\r\n" * "Connection: close\r\n\r\n" * - "3\r\nhey\r\n2\r\n!!\r\n0\r\n\r\n"; - settle_s = 0.1, + "3\r\nhey\r\n2\r\n!!\r\n0\r\n\r\n" ) @test occursin("HTTP/1.1 413 Content Too Large", chunked_raw) @test !isready(called) finally - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end @@ -1378,8 +1180,8 @@ end @test resp2.status == 200 @test String(resp2.body) == "echo" finally - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end @@ -1405,8 +1207,8 @@ end @test take!(empty_written) == UInt(0) @test take!(written) == ncodeunits(payload) finally - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end @@ -1427,8 +1229,8 @@ end @test resp.status == 200 @test String(resp.body) == "ok" finally - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end @@ -1448,8 +1250,8 @@ end resp = HT.request("GET", "http://$(address)/"; status_exception = false, retry = false) @test resp.status == 500 finally - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end # control: once the head is committed (chunked writes it at startwrite), a @@ -1473,8 +1275,8 @@ end # the server wrote a second head after the committed one @test outcome != 500 finally - _run_with_timeout(() -> HT.forceclose(server2); label = "server forceclose") - _run_with_timeout(() -> wait(server2); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server2)) + _run_test_operation(() -> wait(server2)) end end @@ -1511,8 +1313,8 @@ end @test resp2.status == 200 @test echoed[] == "client generic!" finally - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end @@ -1534,24 +1336,20 @@ end try overflow_raw = _raw_http_request( HT.port(server), - "GET /overflow HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n"; - settle_s = 0.3, - wait_for_first_byte = false, + "GET /overflow HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n" ) @test !occursin("toolong", overflow_raw) @test !occursin("content-length: 2", lowercase(overflow_raw)) underflow_raw = _raw_http_request( HT.port(server), - "GET /underflow HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n"; - settle_s = 0.3, - wait_for_first_byte = false, + "GET /underflow HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n" ) @test !occursin("hi", underflow_raw) @test !occursin("content-length: 5", lowercase(underflow_raw)) finally - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end @@ -1569,17 +1367,15 @@ end try raw = _raw_http_request( HT.port(server), - "GET / HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n"; - settle_s = 0.3, - wait_for_first_byte = true, + "GET / HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n" ) lower_raw = lowercase(raw) @test occursin("transfer-encoding: chunked", lower_raw) @test occursin("hello", raw) @test occursin("\r\n0\r\nx-trailer: ok\r\n\r\n", lower_raw) finally - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) end end @@ -1591,17 +1387,15 @@ end address = HT.server_addr(server) client = HT.Client(transport = HT.Transport(max_idle_per_host = 4, max_idle_total = 4)) try - response = _run_with_timeout(() -> HT.get!(client, address, "/live"); label = "live request") + response = _run_test_operation(() -> HT.get!(client, address, "/live")) @test response.status == 200 @test String(_read_all_server_bytes(response.body)) == "ok" - _run_with_timeout(() -> close(server); label = "server close") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> close(server)) + _run_test_operation(() -> wait(server)) @test !isopen(server) - # Bound the post-shutdown probe so Windows CI cannot hang indefinitely - # if a stale keep-alive conn does not surface close immediately. - probe = HT.Request("GET", "/after-shutdown"; host = address, body = HT.EmptyBody(), content_length = 0) - HT.set_deadline!(HT.get_request_context(probe), Int64(time_ns()) + Int64(2_000_000_000)) - @test_throws Exception _run_with_timeout(() -> HT.do!(client, address, probe); timeout_s = 3.0, label = "post-shutdown request") + close(client.transport) + client = HT.Client(transport = HT.Transport(max_idle_per_host = 4, max_idle_total = 4)) + @test_throws Exception HT.get!(client, address, "/after-shutdown"; retry = false) finally close(client.transport) end @@ -1618,26 +1412,23 @@ end end address = HT.server_addr(server) client = HT.Client(transport = HT.Transport(max_idle_per_host = 4, max_idle_total = 4)) - close_task = nothing try - response_task = Threads.@spawn HT.get!(client, address, "/slow") + response_task = errormonitor(Threads.@spawn HT.get!(client, address, "/slow")) take!(started) - close_task = Threads.@spawn close(server) - sleep(0.1) - @test !istaskdone(close_task::Task) + @test HT._begin_shutdown!(server) + HT._close_listener!(server) + HT._request_conn_shutdowns!(server) + @test !HT._close_idle_conns!(server) put!(release, nothing) response = fetch(response_task) @test response.status == 200 @test String(_read_all_server_bytes(response.body)) == "ok" - _run_with_timeout(() -> fetch(close_task::Task); label = "graceful close task") + close(server) @test !isopen(server) finally close(client.transport) - close_task === nothing || HT.@try_ignore begin - fetch(close_task::Task) - end isopen(server) && HT.forceclose(server) - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> wait(server)) end end @@ -1654,8 +1445,7 @@ end try post_response, post_closed = _raw_http_request_until_close( HT.port(server), - "POST /one HTTP/1.1\r\nHost: $(address)\r\nContent-Length: 3\r\n\r\nabc"; - timeout_s = 5.0, + "POST /one HTTP/1.1\r\nHost: $(address)\r\nContent-Length: 3\r\n\r\nabc" ) @test occursin("HTTP/1.1 200", post_response) @test occursin("ok:/one", post_response) @@ -1663,15 +1453,14 @@ end get_response, get_closed = _raw_http_request_until_close( HT.port(server), - "GET /two HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n"; - timeout_s = 5.0, + "GET /two HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n" ) @test occursin("HTTP/1.1 200", get_response) @test occursin("ok:/two", get_response) @test get_closed finally - _run_with_timeout(() -> HT.forceclose(server); label = "server forceclose") - _run_with_timeout(() -> wait(server); label = "server task completion") + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) @test !isopen(server) end end diff --git a/test/http_trim_client_h1_do.jl b/test/http_trim_client_h1_do.jl index 8e3e27a7d..1873c85a3 100644 --- a/test/http_trim_client_h1_do.jl +++ b/test/http_trim_client_h1_do.jl @@ -1,11 +1,8 @@ include("trim_workload_common.jl") const _HTTP_TRIM_H1_DO_LISTENER = Ref{Union{Nothing,Reseau.TCP.Listener}}(nothing) -const _HTTP_TRIM_H1_DO_STARTED = Ref(false) -const _HTTP_TRIM_H1_DO_DONE = Ref(false) function _http_trim_h1_do_server_entry()::Nothing - _HTTP_TRIM_H1_DO_STARTED[] = true listener = _HTTP_TRIM_H1_DO_LISTENER[]::Reseau.TCP.Listener conn = Reseau.TCP.accept(listener) try @@ -19,7 +16,6 @@ function _http_trim_h1_do_server_entry()::Nothing HT.body_close!(request.body) end finally - _HTTP_TRIM_H1_DO_DONE[] = true HTTP.@try_ignore close(conn) end return nothing @@ -32,13 +28,9 @@ function run_http_trim_client_h1_do()::Nothing try listener = Reseau.TCP.listen(Reseau.TCP.loopback_addr(0); backlog = 16) _HTTP_TRIM_H1_DO_LISTENER[] = listener - _HTTP_TRIM_H1_DO_STARTED[] = false - _HTTP_TRIM_H1_DO_DONE[] = false server_task = Task(_http_trim_h1_do_server_entry) schedule(server_task) - start_status = Reseau.IOPoll.timedwait(() -> _HTTP_TRIM_H1_DO_STARTED[], 5.0; pollint = 0.001) - start_status == :timed_out && error("timed out waiting for trim H1 do! server task") addr = Reseau.TCP.addr(listener)::Reseau.TCP.SocketAddrV4 address = "127.0.0.1:$(Int(addr.port))" @@ -80,8 +72,6 @@ function run_http_trim_client_h1_do()::Nothing HTTP.@try_ignore listener === nothing || close(listener::Reseau.TCP.Listener) if server_task !== nothing HTTP.@try_ignore begin - done_status = Reseau.IOPoll.timedwait(() -> _HTTP_TRIM_H1_DO_DONE[] || istaskdone(server_task::Task), 5.0; pollint = 0.001) - done_status == :timed_out && error("timed out waiting for trim H1 do! server task shutdown") wait(server_task) end end diff --git a/test/http_trim_client_h1_raw.jl b/test/http_trim_client_h1_raw.jl index e16c04e1f..ea06aab01 100644 --- a/test/http_trim_client_h1_raw.jl +++ b/test/http_trim_client_h1_raw.jl @@ -1,11 +1,8 @@ include("trim_workload_common.jl") const _HTTP_TRIM_H1_RAW_LISTENER = Ref{Union{Nothing,Reseau.TCP.Listener}}(nothing) -const _HTTP_TRIM_H1_RAW_STARTED = Ref(false) -const _HTTP_TRIM_H1_RAW_DONE = Ref(false) function _http_trim_h1_raw_server_entry()::Nothing - _HTTP_TRIM_H1_RAW_STARTED[] = true listener = _HTTP_TRIM_H1_RAW_LISTENER[]::Reseau.TCP.Listener conn = Reseau.TCP.accept(listener) try @@ -19,7 +16,6 @@ function _http_trim_h1_raw_server_entry()::Nothing HT.body_close!(request.body) end finally - _HTTP_TRIM_H1_RAW_DONE[] = true HTTP.@try_ignore close(conn) end return nothing @@ -32,13 +28,9 @@ function run_http_trim_client_h1_raw()::Nothing try listener = Reseau.TCP.listen(Reseau.TCP.loopback_addr(0); backlog = 16) _HTTP_TRIM_H1_RAW_LISTENER[] = listener - _HTTP_TRIM_H1_RAW_STARTED[] = false - _HTTP_TRIM_H1_RAW_DONE[] = false server_task = Task(_http_trim_h1_raw_server_entry) schedule(server_task) - start_status = Reseau.IOPoll.timedwait(() -> _HTTP_TRIM_H1_RAW_STARTED[], 5.0; pollint = 0.001) - start_status == :timed_out && error("timed out waiting for trim H1 raw server task") addr = Reseau.TCP.addr(listener)::Reseau.TCP.SocketAddrV4 address = "127.0.0.1:$(Int(addr.port))" @@ -57,8 +49,6 @@ function run_http_trim_client_h1_raw()::Nothing HTTP.@try_ignore listener === nothing || close(listener::Reseau.TCP.Listener) if server_task !== nothing HTTP.@try_ignore begin - done_status = Reseau.IOPoll.timedwait(() -> _HTTP_TRIM_H1_RAW_DONE[] || istaskdone(server_task::Task), 5.0; pollint = 0.001) - done_status == :timed_out && error("timed out waiting for trim H1 raw server task shutdown") wait(server_task) end end diff --git a/test/http_trim_client_h1_roundtrip.jl b/test/http_trim_client_h1_roundtrip.jl index 0e941e13c..0f2b36011 100644 --- a/test/http_trim_client_h1_roundtrip.jl +++ b/test/http_trim_client_h1_roundtrip.jl @@ -1,11 +1,8 @@ include("trim_workload_common.jl") const _HTTP_TRIM_H1_ROUNDTRIP_LISTENER = Ref{Union{Nothing,Reseau.TCP.Listener}}(nothing) -const _HTTP_TRIM_H1_ROUNDTRIP_STARTED = Ref(false) -const _HTTP_TRIM_H1_ROUNDTRIP_DONE = Ref(false) function _http_trim_h1_roundtrip_server_entry()::Nothing - _HTTP_TRIM_H1_ROUNDTRIP_STARTED[] = true listener = _HTTP_TRIM_H1_ROUNDTRIP_LISTENER[]::Reseau.TCP.Listener conn = Reseau.TCP.accept(listener) try @@ -19,7 +16,6 @@ function _http_trim_h1_roundtrip_server_entry()::Nothing HT.body_close!(request.body) end finally - _HTTP_TRIM_H1_ROUNDTRIP_DONE[] = true HTTP.@try_ignore close(conn) end return nothing @@ -32,13 +28,9 @@ function run_http_trim_client_h1_roundtrip()::Nothing try listener = Reseau.TCP.listen(Reseau.TCP.loopback_addr(0); backlog = 16) _HTTP_TRIM_H1_ROUNDTRIP_LISTENER[] = listener - _HTTP_TRIM_H1_ROUNDTRIP_STARTED[] = false - _HTTP_TRIM_H1_ROUNDTRIP_DONE[] = false server_task = Task(_http_trim_h1_roundtrip_server_entry) schedule(server_task) - start_status = Reseau.IOPoll.timedwait(() -> _HTTP_TRIM_H1_ROUNDTRIP_STARTED[], 5.0; pollint = 0.001) - start_status == :timed_out && error("timed out waiting for trim H1 roundtrip server task") addr = Reseau.TCP.addr(listener)::Reseau.TCP.SocketAddrV4 address = "127.0.0.1:$(Int(addr.port))" @@ -56,8 +48,6 @@ function run_http_trim_client_h1_roundtrip()::Nothing HTTP.@try_ignore listener === nothing || close(listener::Reseau.TCP.Listener) if server_task !== nothing HTTP.@try_ignore begin - done_status = Reseau.IOPoll.timedwait(() -> _HTTP_TRIM_H1_ROUNDTRIP_DONE[] || istaskdone(server_task::Task), 5.0; pollint = 0.001) - done_status == :timed_out && error("timed out waiting for trim H1 roundtrip server task shutdown") wait(server_task) end end diff --git a/test/http_trim_client_h1_wire.jl b/test/http_trim_client_h1_wire.jl index ac6caa71d..4c29a9206 100644 --- a/test/http_trim_client_h1_wire.jl +++ b/test/http_trim_client_h1_wire.jl @@ -1,11 +1,8 @@ include("trim_workload_common.jl") const _HTTP_TRIM_H1_WIRE_LISTENER = Ref{Union{Nothing,Reseau.TCP.Listener}}(nothing) -const _HTTP_TRIM_H1_WIRE_STARTED = Ref(false) -const _HTTP_TRIM_H1_WIRE_DONE = Ref(false) function _http_trim_h1_wire_server_entry()::Nothing - _HTTP_TRIM_H1_WIRE_STARTED[] = true listener = _HTTP_TRIM_H1_WIRE_LISTENER[]::Reseau.TCP.Listener conn = Reseau.TCP.accept(listener) try @@ -19,7 +16,6 @@ function _http_trim_h1_wire_server_entry()::Nothing HT.body_close!(request.body) end finally - _HTTP_TRIM_H1_WIRE_DONE[] = true HTTP.@try_ignore close(conn) end return nothing @@ -32,13 +28,9 @@ function run_http_trim_client_h1_wire()::Nothing try listener = Reseau.TCP.listen(Reseau.TCP.loopback_addr(0); backlog = 16) _HTTP_TRIM_H1_WIRE_LISTENER[] = listener - _HTTP_TRIM_H1_WIRE_STARTED[] = false - _HTTP_TRIM_H1_WIRE_DONE[] = false server_task = Task(_http_trim_h1_wire_server_entry) schedule(server_task) - start_status = Reseau.IOPoll.timedwait(() -> _HTTP_TRIM_H1_WIRE_STARTED[], 5.0; pollint = 0.001) - start_status == :timed_out && error("timed out waiting for trim H1 wire server task") addr = Reseau.TCP.addr(listener)::Reseau.TCP.SocketAddrV4 address = "127.0.0.1:$(Int(addr.port))" @@ -60,8 +52,6 @@ function run_http_trim_client_h1_wire()::Nothing HTTP.@try_ignore listener === nothing || close(listener::Reseau.TCP.Listener) if server_task !== nothing HTTP.@try_ignore begin - done_status = Reseau.IOPoll.timedwait(() -> _HTTP_TRIM_H1_WIRE_DONE[] || istaskdone(server_task::Task), 5.0; pollint = 0.001) - done_status == :timed_out && error("timed out waiting for trim H1 wire server task shutdown") wait(server_task) end end diff --git a/test/http_trim_client_h2_roundtrip.jl b/test/http_trim_client_h2_roundtrip.jl index c2b296f1b..1ab8fb520 100644 --- a/test/http_trim_client_h2_roundtrip.jl +++ b/test/http_trim_client_h2_roundtrip.jl @@ -1,8 +1,6 @@ include("trim_workload_common.jl") const _HTTP_TRIM_H2_ROUNDTRIP_LISTENER = Ref{Union{Nothing,Reseau.TCP.Listener}}(nothing) -const _HTTP_TRIM_H2_ROUNDTRIP_STARTED = Ref(false) -const _HTTP_TRIM_H2_ROUNDTRIP_DONE = Ref(false) function _http_trim_h2_roundtrip_write_all!(conn::Reseau.TCP.Conn, bytes::Vector{UInt8})::Nothing total = 0 @@ -47,7 +45,6 @@ function _http_trim_h2_roundtrip_next_headers!(reader::IO)::HT.HeadersFrame end function _http_trim_h2_roundtrip_server_entry()::Nothing - _HTTP_TRIM_H2_ROUNDTRIP_STARTED[] = true listener = _HTTP_TRIM_H2_ROUNDTRIP_LISTENER[]::Reseau.TCP.Listener conn = Reseau.TCP.accept(listener) try @@ -78,7 +75,6 @@ function _http_trim_h2_roundtrip_server_entry()::Nothing _http_trim_h2_roundtrip_write_frame!(conn, HT.HeadersFrame(UInt32(1), false, true, response_block)) _http_trim_h2_roundtrip_write_frame!(conn, HT.DataFrame(UInt32(1), true, collect(codeunits("h2-roundtrip")))) finally - _HTTP_TRIM_H2_ROUNDTRIP_DONE[] = true HTTP.@try_ignore close(conn) end return nothing @@ -91,13 +87,9 @@ function run_http_trim_client_h2_roundtrip()::Nothing try listener = Reseau.TCP.listen(Reseau.TCP.loopback_addr(0); backlog = 16) _HTTP_TRIM_H2_ROUNDTRIP_LISTENER[] = listener - _HTTP_TRIM_H2_ROUNDTRIP_STARTED[] = false - _HTTP_TRIM_H2_ROUNDTRIP_DONE[] = false server_task = Task(_http_trim_h2_roundtrip_server_entry) schedule(server_task) - start_status = Reseau.IOPoll.timedwait(() -> _HTTP_TRIM_H2_ROUNDTRIP_STARTED[], 5.0; pollint = 0.001) - start_status == :timed_out && error("timed out waiting for trim H2 roundtrip server task") addr = Reseau.TCP.addr(listener)::Reseau.TCP.SocketAddrV4 address = "127.0.0.1:$(Int(addr.port))" @@ -115,8 +107,6 @@ function run_http_trim_client_h2_roundtrip()::Nothing HTTP.@try_ignore listener === nothing || close(listener::Reseau.TCP.Listener) HTTP.@try_ignore begin if server_task !== nothing - done_status = Reseau.IOPoll.timedwait(() -> _HTTP_TRIM_H2_ROUNDTRIP_DONE[] || istaskdone(server_task::Task), 5.0; pollint = 0.001) - done_status == :timed_out && error("timed out waiting for trim H2 roundtrip server task shutdown") wait(server_task) end end diff --git a/test/http_trim_client_h2_tcp_roundtrip.jl b/test/http_trim_client_h2_tcp_roundtrip.jl index 7f7e5d373..50d606edf 100644 --- a/test/http_trim_client_h2_tcp_roundtrip.jl +++ b/test/http_trim_client_h2_tcp_roundtrip.jl @@ -1,8 +1,6 @@ include("trim_workload_common.jl") const _HTTP_TRIM_H2_TCP_ROUNDTRIP_LISTENER = Ref{Union{Nothing,Reseau.TCP.Listener}}(nothing) -const _HTTP_TRIM_H2_TCP_ROUNDTRIP_STARTED = Ref(false) -const _HTTP_TRIM_H2_TCP_ROUNDTRIP_DONE = Ref(false) function _http_trim_h2_tcp_roundtrip_write_all!(conn::Reseau.TCP.Conn, bytes::Vector{UInt8})::Nothing total = 0 @@ -47,7 +45,6 @@ function _http_trim_h2_tcp_roundtrip_next_headers!(reader::IO)::HT.HeadersFrame end function _http_trim_h2_tcp_roundtrip_server_entry()::Nothing - _HTTP_TRIM_H2_TCP_ROUNDTRIP_STARTED[] = true listener = _HTTP_TRIM_H2_TCP_ROUNDTRIP_LISTENER[]::Reseau.TCP.Listener conn = Reseau.TCP.accept(listener) try @@ -78,7 +75,6 @@ function _http_trim_h2_tcp_roundtrip_server_entry()::Nothing _http_trim_h2_tcp_roundtrip_write_frame!(conn, HT.HeadersFrame(UInt32(1), false, true, response_block)) _http_trim_h2_tcp_roundtrip_write_frame!(conn, HT.DataFrame(UInt32(1), true, collect(codeunits("h2-tcp-roundtrip")))) finally - _HTTP_TRIM_H2_TCP_ROUNDTRIP_DONE[] = true HTTP.@try_ignore close(conn) end return nothing @@ -92,13 +88,9 @@ function run_http_trim_client_h2_tcp_roundtrip()::Nothing try listener = Reseau.TCP.listen(Reseau.TCP.loopback_addr(0); backlog = 16) _HTTP_TRIM_H2_TCP_ROUNDTRIP_LISTENER[] = listener - _HTTP_TRIM_H2_TCP_ROUNDTRIP_STARTED[] = false - _HTTP_TRIM_H2_TCP_ROUNDTRIP_DONE[] = false server_task = Task(_http_trim_h2_tcp_roundtrip_server_entry) schedule(server_task) - start_status = Reseau.IOPoll.timedwait(() -> _HTTP_TRIM_H2_TCP_ROUNDTRIP_STARTED[], 5.0; pollint = 0.001) - start_status == :timed_out && error("timed out waiting for trim H2 TCP roundtrip server task") addr = Reseau.TCP.addr(listener)::Reseau.TCP.SocketAddrV4 address = "127.0.0.1:$(Int(addr.port))" @@ -120,8 +112,6 @@ function run_http_trim_client_h2_tcp_roundtrip()::Nothing HTTP.@try_ignore listener === nothing || close(listener::Reseau.TCP.Listener) HTTP.@try_ignore begin if server_task !== nothing - done_status = Reseau.IOPoll.timedwait(() -> _HTTP_TRIM_H2_TCP_ROUNDTRIP_DONE[] || istaskdone(server_task::Task), 5.0; pollint = 0.001) - done_status == :timed_out && error("timed out waiting for trim H2 TCP roundtrip server task shutdown") wait(server_task) end end diff --git a/test/http_trim_client_h2_wire.jl b/test/http_trim_client_h2_wire.jl index 2953cc85d..01e38564b 100644 --- a/test/http_trim_client_h2_wire.jl +++ b/test/http_trim_client_h2_wire.jl @@ -1,8 +1,6 @@ include("trim_workload_common.jl") const _HTTP_TRIM_H2_WIRE_LISTENER = Ref{Union{Nothing,Reseau.TCP.Listener}}(nothing) -const _HTTP_TRIM_H2_WIRE_STARTED = Ref(false) -const _HTTP_TRIM_H2_WIRE_DONE = Ref(false) function _http_trim_write_all_h2!(conn::Reseau.TCP.Conn, bytes::Vector{UInt8})::Nothing total = 0 @@ -36,7 +34,6 @@ function _http_trim_write_h2_frame!(conn::Reseau.TCP.Conn, frame::HT.AbstractFra end function _http_trim_h2_wire_server_entry()::Nothing - _HTTP_TRIM_H2_WIRE_STARTED[] = true listener = _HTTP_TRIM_H2_WIRE_LISTENER[]::Reseau.TCP.Listener conn = Reseau.TCP.accept(listener) try @@ -71,7 +68,6 @@ function _http_trim_h2_wire_server_entry()::Nothing _http_trim_write_h2_frame!(conn, HT.HeadersFrame(UInt32(1), false, true, response_block)) _http_trim_write_h2_frame!(conn, HT.DataFrame(UInt32(1), true, collect(codeunits("h2-wire")))) finally - _HTTP_TRIM_H2_WIRE_DONE[] = true HTTP.@try_ignore close(conn) end return nothing @@ -84,13 +80,9 @@ function run_http_trim_client_h2_wire()::Nothing try listener = Reseau.TCP.listen(Reseau.TCP.loopback_addr(0); backlog = 16) _HTTP_TRIM_H2_WIRE_LISTENER[] = listener - _HTTP_TRIM_H2_WIRE_STARTED[] = false - _HTTP_TRIM_H2_WIRE_DONE[] = false server_task = Task(_http_trim_h2_wire_server_entry) schedule(server_task) - start_status = Reseau.IOPoll.timedwait(() -> _HTTP_TRIM_H2_WIRE_STARTED[], 5.0; pollint = 0.001) - start_status == :timed_out && error("timed out waiting for trim H2 wire server task") addr = Reseau.TCP.addr(listener)::Reseau.TCP.SocketAddrV4 address = "127.0.0.1:$(Int(addr.port))" @@ -129,8 +121,6 @@ function run_http_trim_client_h2_wire()::Nothing HTTP.@try_ignore listener === nothing || close(listener::Reseau.TCP.Listener) HTTP.@try_ignore begin if server_task !== nothing - done_status = Reseau.IOPoll.timedwait(() -> _HTTP_TRIM_H2_WIRE_DONE[] || istaskdone(server_task::Task), 5.0; pollint = 0.001) - done_status == :timed_out && error("timed out waiting for trim H2 wire server task shutdown") wait(server_task) end end diff --git a/test/http_websocket_client_tests.jl b/test/http_websocket_client_tests.jl index fef7575ef..88311e9bc 100644 --- a/test/http_websocket_client_tests.jl +++ b/test/http_websocket_client_tests.jl @@ -333,20 +333,21 @@ end @testset "HTTP.WebSockets client handshake response_header_timeout" begin listener = nothing task = nothing + release = Channel{Nothing}(1) try listener, task, address = _ws_server() do conn - request = _read_ws_request(conn) - @test request.target == "/slow" - sleep(0.20) + _ = conn + take!(release) end err = try - W.open("ws://$address/slow"; response_header_timeout = 0.05) + W.open("ws://$address/slow"; response_header_timeout = 1.0e-9) nothing catch ex ex end @test err isa HTTP.TimeoutError finally + isready(release) || put!(release, nothing) _close_quiet!(listener) _close_quiet!(task) end diff --git a/test/http_websocket_integration_tests.jl b/test/http_websocket_integration_tests.jl index 0e47897f7..2aaff162d 100644 --- a/test/http_websocket_integration_tests.jl +++ b/test/http_websocket_integration_tests.jl @@ -161,10 +161,13 @@ end @testset "HTTP.WebSockets.upgrade mixes HTTP and WebSocket routes" begin # Manual upgrade from a normal HTTP.listen! stream handler (1.x parity): one - # server serves both a normal HTTP route and a WebSocket route. read_timeout - # is short to prove upgrade() clears the server's per-request read deadline. - server = HT.listen!("127.0.0.1", 0; listenany = true, read_timeout = 1) do stream + # server serves both a normal HTTP route and a WebSocket route. + server = HT.listen!("127.0.0.1", 0; listenany = true) do stream if W.isupgrade(stream.message) + # The HTTP server arms a request read deadline before calling the + # handler. Make it already expired so a working upgrade must clear + # it before the WebSocket read task starts. + NC.set_read_deadline!(stream.tracked.conn::NC.Conn, Int64(1)) W.upgrade(stream) do ws for msg in ws W.send(ws, msg) @@ -189,12 +192,9 @@ end W.send(ws, UInt8[1, 2, 3]) @test W.receive(ws) == UInt8[1, 2, 3] end - # upgrade() must clear the per-request read deadline: a pause longer than - # read_timeout (1s) must not tear down the WebSocket session. W.open("ws://$address/ws") do ws W.send(ws, "a") @test W.receive(ws) == "a" - sleep(1.5) W.send(ws, "b") @test W.receive(ws) == "b" end @@ -224,7 +224,7 @@ end W.open("ws://$address/isready") do ws @test !isready(ws) # nothing buffered yet W.send(ws, "ping") - @test timedwait(() -> isready(ws), 5.0) != :timed_out # echo arrives in the channel + wait(ws.readchannel) @test isready(ws) # a message is ready; receive won't block @test W.receive(ws) == "ping" @test !isready(ws) # drained @@ -238,17 +238,21 @@ end # The read idle timeout resets on each received message and fires only after # `read_idle_timeout` seconds with no data: it must not interrupt an active # stream, but must surface a silently-stalled connection. + release = Channel{Nothing}(1) server = W.listen!("127.0.0.1", 0) do ws W.send(ws, "hello") - sleep(3) # stay silent so the client idle timeout fires first + take!(release) end try url = "ws://$(W.server_addr(server))/" msgs = String[] err = nothing try - W.open(url; read_idle_timeout = 1.0, suppress_close_error = true) do ws + W.open(url; read_idle_timeout=60, suppress_close_error=true) do ws push!(msgs, String(W.receive(ws))) # "hello" arrives during activity + @test ws.read_idle_timeout_ns == 60_000_000_000 + ws.read_idle_timeout_ns = Int64(1) + W._ws_arm_read_deadline!(ws.stream, Int64(1)) W.receive(ws) # no more data -> idle timeout end catch e @@ -259,6 +263,7 @@ end @test err !== nothing && (err::W.WebSocketError).message.code == 1006 @test err !== nothing && occursin("idle timeout", (err::W.WebSocketError).message.reason) finally + isready(release) || put!(release, nothing) close(server) end end diff --git a/test/http_websocket_server_tests.jl b/test/http_websocket_server_tests.jl index 4c14d6b8d..7f2ac4f2d 100644 --- a/test/http_websocket_server_tests.jl +++ b/test/http_websocket_server_tests.jl @@ -168,21 +168,25 @@ end end @testset "HTTP.WebSockets client read_idle_timeout over wss (#1062)" begin + release = Channel{Nothing}(1) server = W.listen!( "127.0.0.1", 0; tls_config = TL.Config(verify_peer = false, cert_file = _TLS_CERT_PATH, key_file = _TLS_KEY_PATH), ) do ws W.send(ws, "hello") - sleep(3) # stay silent so the client idle timeout fires first + take!(release) end try address = W.server_addr(server) msgs = String[] err = nothing try - W.open("wss://$address/"; read_idle_timeout = 1.0, require_ssl_verification = false, suppress_close_error = true) do ws + W.open("wss://$address/"; read_idle_timeout=60, require_ssl_verification=false, suppress_close_error=true) do ws push!(msgs, String(W.receive(ws))) # "hello" arrives during activity + @test ws.read_idle_timeout_ns == 60_000_000_000 + ws.read_idle_timeout_ns = Int64(1) + W._ws_arm_read_deadline!(ws.stream, Int64(1)) W.receive(ws) # no more data -> idle timeout end catch e @@ -193,6 +197,7 @@ end @test err !== nothing && (err::W.WebSocketError).message.code == 1006 @test err !== nothing && occursin("idle timeout", (err::W.WebSocketError).message.reason) finally + isready(release) || put!(release, nothing) close(server) end end @@ -314,6 +319,7 @@ end @testset "HTTP.WebSockets server close notifies active sessions" begin started = Channel{Nothing}(1) finished = Channel{Nothing}(1) + release_handler = Channel{Nothing}(1) server = W.listen!("127.0.0.1", 0) do ws put!(started, nothing) try @@ -323,16 +329,20 @@ end catch finally put!(finished, nothing) + take!(release_handler) end end ws = nothing + forceclose_task = nothing try address = W.server_addr(server) ws = W.open("ws://$address/shutdown") take!(started) - close(server) - @test isready(finished) + forceclose_task = errormonitor(Threads.@spawn W.forceclose(server)) take!(finished) + fetch(forceclose_task::Task) + put!(release_handler, nothing) + wait(server) err = try W.receive(ws::W.WebSocket) nothing @@ -342,6 +352,10 @@ end @test err isa W.WebSocketError @test (err::W.WebSocketError).message.code == 1001 finally + isready(release_handler) || HTTP.@try_ignore put!(release_handler, nothing) + forceclose_task === nothing || HTTP.@try_ignore fetch(forceclose_task::Task) ws === nothing || HTTP.@try_ignore close(ws::W.WebSocket) + HTTP.@try_ignore W.forceclose(server) + HTTP.@try_ignore wait(server) end end diff --git a/test/runtests.jl b/test/runtests.jl index 68ec70005..e1b61af6b 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -16,39 +16,6 @@ const ND = Reseau.HostResolvers const NC = Reseau.TCP const IP = Reseau.IOPoll -# --- Hang diagnostic watchdog --------------------------------------------- -# An intermittent hang strikes various timeout/network tests on Windows CI, in a -# different testset each run (root cause: Reseau IOCP read-deadline strand, -# JuliaServices/Reseau.jl#107). If the suite exceeds this budget, dump every -# task's backtrace so CI captures a stack trace pinpointing the stuck task, then -# hard-exit before the job's wall-clock cap swallows the output. Tune/disable via -# HTTP_HANG_WATCHDOG_S (<= 0 disables). -const _HANG_WATCHDOG_BUDGET_S = parse(Float64, get(ENV, "HTTP_HANG_WATCHDOG_S", "1200")) -function _arm_hang_watchdog() - _HANG_WATCHDOG_BUDGET_S > 0 || return nothing - Threads.@spawn begin - deadline = time() + _HANG_WATCHDOG_BUDGET_S - while time() < deadline - sleep(5.0) - end - try - println(stderr, "\n\n==== HTTP HANG WATCHDOG: suite exceeded $(_HANG_WATCHDOG_BUDGET_S)s ====") - println(stderr, "==== dumping all task backtraces to locate the stuck task ====") - flush(stdout) - flush(stderr) - ccall(:jl_print_task_backtraces, Cvoid, (Cint,), 0) - flush(stderr) - catch err - println(stderr, "==== HANG WATCHDOG: backtrace dump failed: ", err) - flush(stderr) - end - sleep(2.0) - ccall(:exit, Cvoid, (Cint,), 1) - end - return nothing -end -_arm_hang_watchdog() - function _include_with_progress(path::AbstractString) _log_test_progress("[runtests] include START: $(path)") include(path) @@ -95,6 +62,7 @@ function _http_quiesce_windows_state!(label::AbstractString)::Nothing end test_files = [ + "test_policy_tests.jl", "http_core_tests.jl", "http1_wire_tests.jl", "http_cookie_tests.jl", @@ -125,10 +93,6 @@ test_files = [ "trim_compile_tests.jl", ] -if _http_windows_ci() - _run_test_file("windows_warmup.jl") -end - for test_file in test_files _run_test_file(test_file) end diff --git a/test/test_policy_tests.jl b/test/test_policy_tests.jl new file mode 100644 index 000000000..ce02931ab --- /dev/null +++ b/test/test_policy_tests.jl @@ -0,0 +1,24 @@ +using Test + +@testset "test synchronization does not depend on wall-clock timing" begin + forbidden = [ + r"\bsleep\s*\(" => "sleep-based coordination", + r"\btimedwait\s*\(" => "deadline polling", + r"\btime_ns\s*\(" => "monotonic-clock reads", + r"\btime\s*\(" => "wall-clock reads", + r"@elapsed\b" => "elapsed-time assertions", + r"\bTimer\s*\(" => "timer-based coordination", + r"\bnow\s*\(" => "current-date reads", + r"\bpollint\s*=" => "polling intervals", + r"\btimeout_s\s*=" => "test-helper timeouts", + ] + policy_file = abspath(@__FILE__) + for path in sort(filter(path -> endswith(path, ".jl"), readdir(@__DIR__; join=true))) + abspath(path) == policy_file && continue + source = read(path, String) + for (pattern, description) in forbidden + occursin(pattern, source) && error("$(basename(path)) uses forbidden $(description)") + end + end + @test true +end diff --git a/test/trim_compile_tests.jl b/test/trim_compile_tests.jl index 0d237b17c..5fe450fad 100644 --- a/test/trim_compile_tests.jl +++ b/test/trim_compile_tests.jl @@ -4,18 +4,6 @@ using HTTP const _TRIM_SUPPORTED = VERSION >= v"1.12.0-rc1" const _JULIAC_ENTRYPOINT_EXPR = "using JuliaC; if isdefined(JuliaC, :main); JuliaC.main(ARGS); else JuliaC._main_cli(ARGS); end" -function _trim_compile_timeout_s()::Float64 - default = if Sys.iswindows() - "1200.0" - elseif (VERSION.major, VERSION.minor) >= (1, 13) - # Julia pre can spend most of the old budget precompiling before JuliaC compiles. - "240.0" - else - "120.0" - end - return parse(Float64, get(ENV, "HTTP_TRIM_COMPILE_TIMEOUT_S", default)) -end - function _trim_project_path()::String active_project = Base.active_project() if active_project !== nothing && isfile(active_project) @@ -24,28 +12,26 @@ function _trim_project_path()::String return normpath(joinpath(@__DIR__, "..")) end -function _run_trim_compile(project_path::String, script_path::String, output_name::String; timeout_s::Float64 = _trim_compile_timeout_s(), bundle_dir::Union{Nothing, String} = nothing) +function _run_trim_compile(project_path::String, script_path::String, output_name::String; bundle_dir::Union{Nothing, String} = nothing) julia_exe = joinpath(Sys.BINDIR, Base.julia_exename()) cmd = if bundle_dir === nothing `$julia_exe --startup-file=no --history-file=no --code-coverage=none --project=$project_path -e $(_JULIAC_ENTRYPOINT_EXPR) -- --output-exe $output_name --project=$project_path --experimental --trim=safe $script_path` else `$julia_exe --startup-file=no --history-file=no --code-coverage=none --project=$project_path -e $(_JULIAC_ENTRYPOINT_EXPR) -- --output-exe $output_name --bundle $bundle_dir --project=$project_path --experimental --trim=safe $script_path` end - return _run_command_with_timeout(cmd; timeout_s = timeout_s, log_label = "compile") + return _run_trim_command(cmd) end -function _run_trim_executable(run_cmd; timeout_s::Float64 = 30.0) - return _run_command_with_timeout(run_cmd; timeout_s = timeout_s, log_label = "run") +function _run_trim_executable(run_cmd) + return _run_trim_command(run_cmd) end -function _run_command_with_timeout(cmd::Cmd; timeout_s::Float64, log_label::String) +function _run_trim_command(cmd::Cmd) output_path = tempname() out = open(output_path, "w") exit_code = -1 - timed_out = false try - proc = run(pipeline(ignorestatus(cmd), stdout = out, stderr = out); wait = false) - timed_out = _wait_process_with_timeout!(proc; timeout_s = timeout_s, log_label = log_label) + proc = run(pipeline(ignorestatus(cmd), stdout = out, stderr = out)) exit_code = something(proc.exitcode, -1) finally close(out) @@ -57,65 +43,7 @@ function _run_command_with_timeout(cmd::Cmd; timeout_s::Float64, log_label::Stri finally rm(output_path; force = true) end - return exit_code, output, timed_out -end - -function _wait_process_with_timeout!(proc::Base.Process; timeout_s::Float64, log_label::String) - started_at = time() - next_log_at = started_at + 10.0 - timed_out = false - while Base.process_running(proc) - now = time() - if now - started_at >= timeout_s - timed_out = true - HTTP.@try_ignore kill(proc) - _kill_windows_process_tree!(proc) - _wait_process_exit_after_kill!(proc; timeout_s = 5.0, log_label = log_label) - return timed_out - end - if now >= next_log_at - elapsed = round(now - started_at; digits = 1) - println("[trim] $(log_label) WAIT $(elapsed)s") - flush(stdout) - next_log_at = now + 10.0 - end - sleep(0.1) - end - if !Base.process_running(proc) - HTTP.@try_ignore wait(proc) - end - return timed_out -end - -function _kill_windows_process_tree!(proc::Base.Process)::Nothing - Sys.iswindows() || return nothing - pid = try - getpid(proc) - catch - return nothing - end - HTTP.@try_ignore run(ignorestatus(`taskkill /PID $pid /T /F`)) - return nothing -end - -function _wait_process_exit_after_kill!(proc::Base.Process; timeout_s::Float64, log_label::String)::Nothing - deadline = time() + timeout_s - while Base.process_running(proc) && time() < deadline - sleep(0.1) - end - if Base.process_running(proc) - println("[trim] $(log_label) process still running after kill; continuing after timeout") - flush(stdout) - end - return nothing -end - -function _trim_timeout_error(kind::String, script_file::String, output::String = "") - msg = "trim $kind timed out for $(script_file)" - if !isempty(output) - msg = string(msg, "\n---- captured output ----\n", output, "\n---- end captured output ----") - end - throw(ArgumentError(msg)) + return exit_code, output end function _maybe_print_output(header::String, output::String) @@ -126,12 +54,6 @@ function _maybe_print_output(header::String, output::String) return nothing end -function _trim_executable_timeout_s(script_path::String)::Float64 - _ = script_path - default = Sys.iswindows() ? "180.0" : "30.0" - return parse(Float64, get(ENV, "HTTP_TRIM_EXE_TIMEOUT_S", default)) -end - function _trim_selected_workloads(workloads::Vector{Tuple{String, String}})::Vector{Tuple{String, String}} only = strip(get(ENV, "HTTP_TRIM_ONLY", "")) isempty(only) && return workloads @@ -165,14 +87,10 @@ function _run_trim_case(project_path::String, script_file::String, output_name:: script_path = joinpath(@__DIR__, script_file) @test isfile(script_path) println("[trim] compile START $(script_file)") - start_t = time() mktempdir() do tmpdir cd(tmpdir) do bundle_dir = _trim_use_bundle() ? joinpath(tmpdir, "bundle") : nothing - exit_code, output, timed_out = _run_trim_compile(project_path, script_path, output_name; bundle_dir = bundle_dir) - if timed_out - _trim_timeout_error("compile", script_file, output) - end + exit_code, output = _run_trim_compile(project_path, script_path, output_name; bundle_dir = bundle_dir) totals = _parse_trim_verify_totals(output) trim_errors, trim_warnings = if totals === nothing fallback = _count_trim_verify_messages(output) @@ -198,18 +116,14 @@ function _run_trim_case(project_path::String, script_file::String, output_name:: return nothing end run_cmd = Sys.iswindows() ? `$(abspath(run_path))` : `$(abspath(run_path))` - run_timeout_s = _trim_executable_timeout_s(script_path) - run_exit, run_output, run_timed_out = _run_trim_executable(run_cmd; timeout_s = run_timeout_s) - if run_timed_out - _trim_timeout_error("executable run", script_file, run_output) - end + run_exit, run_output = _run_trim_executable(run_cmd) if run_exit != 0 _maybe_print_output("---- trim executable output ($(script_file)) ----", run_output) end @test run_exit == 0 end end - println("[trim] compile DONE $(script_file) ($(round(time() - start_t; digits = 2))s)") + println("[trim] compile DONE $(script_file)") return nothing end diff --git a/test/windows_warmup.jl b/test/windows_warmup.jl deleted file mode 100644 index 48aad2bea..000000000 --- a/test/windows_warmup.jl +++ /dev/null @@ -1,193 +0,0 @@ -function _http_windows_ci_warmup()::Bool - return _http_windows_ci() -end - -const _HTTP_WINDOWS_WARMED = Ref(false) - -function _http_close_quiet!(x) - x === nothing && return nothing - HTTP.@try_ignore close(x) - return nothing -end - -function _http_wait_task_done(task::Task; timeout_s::Float64 = 3.0)::Bool - deadline = time() + timeout_s - while time() < deadline - istaskdone(task) && return true - IP.sleep(0.01) - end - return istaskdone(task) -end - -function _http_write_all_tcp!(conn::NC.Conn, bytes::Vector{UInt8})::Nothing - total = 0 - while total < length(bytes) - n = write(conn, bytes[(total + 1):end]) - n > 0 || error("expected write progress") - total += n - end - return nothing -end - -function _http_send_warmup_response!(conn::NC.Conn, request::HTTP.Request)::Nothing - payload = collect(codeunits("windows-warmup:" * request.target)) - response = HTTP.Response( - 200, - HTTP.BytesBody(payload); - content_length = length(payload), - request = request, - ) - io = IOBuffer() - HTTP.write_response!(io, response) - _http_write_all_tcp!(conn, take!(io)) - return nothing -end - -function _http_ipv6_supported()::Bool - listener = nothing - try - listener = NC.listen("tcp6", "[::1]:0"; backlog = 1) - return true - catch - return false - finally - _http_close_quiet!(listener) - IP.shutdown!() - end -end - -function _http_warm_windows_resolver_paths!()::Nothing - _http_windows_ci_warmup() || return nothing - _HTTP_WINDOWS_WARMED[] && return nothing - _HTTP_WINDOWS_WARMED[] = true - - listener = nothing - client = nothing - server = nothing - try - listener = NC.listen("tcp", "127.0.0.1:0"; backlog = 4) - port = Int((NC.addr(listener)::NC.SocketAddrV4).port) - accept_task = errormonitor(Threads.@spawn NC.accept(listener)) - client = NC.connect("tcp", ND.join_host_port("127.0.0.1", port); timeout_ns = 3_000_000_000) - _http_wait_task_done(accept_task) || error("HTTP Windows warmup accept timed out") - server = fetch(accept_task) - finally - _http_close_quiet!(server) - _http_close_quiet!(client) - _http_close_quiet!(listener) - IP.shutdown!() - end - - _http_ipv6_supported() || return nothing - - listener = nothing - client = nothing - server = nothing - try - listener = NC.listen("tcp6", "[::1]:0"; backlog = 4) - port = Int((NC.addr(listener)::NC.SocketAddrV6).port) - resolver = ND.StaticResolver(hosts = Dict( - "warmup.test" => NC.SocketEndpoint[ - NC.loopback_addr(port), - NC.loopback_addr6(port), - ], - )) - accept_task = errormonitor(Threads.@spawn NC.accept(listener)) - client = NC.connect( - "tcp", - "warmup.test:$port"; - resolver = resolver, - local_addr = NC.loopback_addr6(0), - fallback_delay_ns = 5_000_000_000, - ) - _http_wait_task_done(accept_task) || error("HTTP Windows IPv6 warmup accept timed out") - server = fetch(accept_task) - finally - _http_close_quiet!(server) - _http_close_quiet!(client) - _http_close_quiet!(listener) - IP.shutdown!() - end - - listener = nothing - try - listener = NC.listen("tcp4", "127.0.0.1:0"; backlog = 4) - port = Int((NC.addr(listener)::NC.SocketAddrV4).port) - close(listener) - listener = nothing - resolver = ND.StaticResolver(hosts = Dict( - "warmup-fail.test" => NC.SocketEndpoint[ - NC.loopback_addr6(port), - NC.loopback_addr(port), - ], - )) - err = try - NC.connect( - "tcp", - "warmup-fail.test:$port"; - resolver = resolver, - timeout_ns = 1_500_000_000, - fallback_delay_ns = 1_000_000, - ) - nothing - catch ex - ex - end - err isa ND.OpError || error("expected Windows warmup fallback failure, got $(typeof(err))") - finally - _http_close_quiet!(listener) - IP.shutdown!() - end - return nothing -end - -function _http_warm_windows_client_paths!()::Nothing - _http_windows_ci_warmup() || return nothing - success = false - last_err = nothing - for _ in 1:6 - listener = nothing - server_task = nothing - try - listener = NC.listen("tcp", "127.0.0.1:0"; backlog = 4) - port = Int((NC.addr(listener)::NC.SocketAddrV4).port) - address = ND.join_host_port("127.0.0.1", port) - server_task = Threads.@spawn begin - conn = NC.accept(listener) - try - request = HTTP.read_request(HTTP._ConnReader(conn)) - _http_send_warmup_response!(conn, request) - finally - _http_close_quiet!(conn) - end - return nothing - end - response = HTTP.get( - "http://$(address)/ok"; - proxy = HTTP.ProxyConfig(), - connect_timeout = 0.25, - readtimeout = 0.25, - ) - String(response.body) == "windows-warmup:/ok" || error("unexpected HTTP Windows warmup response") - _http_wait_task_done(server_task; timeout_s = 3.0) || error("HTTP Windows warmup server task timed out") - fetch(server_task) - success = true - break - catch err - last_err = err - IP.sleep(0.05) - finally - _http_close_quiet!(listener) - server_task !== nothing && _http_wait_task_done(server_task; timeout_s = 0.5) - IP.shutdown!() - end - end - success || throw(last_err === nothing ? ErrorException("HTTP Windows warmup failed without an error") : last_err) - return nothing -end - -@testset "HTTP Windows resolver warmup" begin - _http_warm_windows_resolver_paths!() - _http_warm_windows_client_paths!() - @test true -end