From c68aef2a3ffc810a0a6634a3d9dc9fff65ab18b6 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Thu, 6 Aug 2026 10:04:19 -0600 Subject: [PATCH 1/2] test(ci): remove wall-clock synchronization Replace sleeps, polling deadlines, and elapsed-time assertions with lifecycle signals, exact protocol reads, and expired deadlines. Add a policy test and contributor guidance to keep the suite deterministic across variable CI runner load. --- src/http_client_timeouts.jl | 3 +- src/http_retry.jl | 7 +- test/README.md | 26 + test/http1_wire_tests.jl | 4 +- test/http2_client_tests.jl | 231 ++++----- test/http2_server_tests.jl | 291 +++++------- test/http_client_proxy_tests.jl | 31 +- test/http_client_tests.jl | 158 ++----- test/http_client_transport_tests.jl | 236 ++-------- test/http_cookie_tests.jl | 2 +- test/http_core_tests.jl | 2 +- test/http_handlers_tests.jl | 11 +- test/http_integration_tests.jl | 17 +- test/http_parity_tests.jl | 4 +- test/http_retry_tests.jl | 55 +-- test/http_server_http1_tests.jl | 548 +++++++--------------- test/http_trim_client_h1_do.jl | 10 - test/http_trim_client_h1_raw.jl | 10 - test/http_trim_client_h1_roundtrip.jl | 10 - test/http_trim_client_h1_wire.jl | 10 - test/http_trim_client_h2_roundtrip.jl | 10 - test/http_trim_client_h2_tcp_roundtrip.jl | 10 - test/http_trim_client_h2_wire.jl | 10 - test/http_websocket_client_tests.jl | 9 +- test/http_websocket_integration_tests.jl | 23 +- test/http_websocket_server_tests.jl | 9 +- test/runtests.jl | 38 +- test/test_policy_tests.jl | 24 + test/trim_compile_tests.jl | 106 +---- test/windows_warmup.jl | 193 -------- 30 files changed, 615 insertions(+), 1483 deletions(-) create mode 100644 test/README.md create mode 100644 test/test_policy_tests.jl delete mode 100644 test/windows_warmup.jl 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/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..6df84cb08 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 @@ -2650,22 +2621,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 +2669,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 +2717,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..f99663717 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,16 @@ 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) - end - @test elapsed < 0.5 + @test_throws Reseau.IOPoll.DeadlineExceededError HT._reserve_h2_send_window!(send_state, UInt32(1), 1, Int64(1)) 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 +1764,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 +1795,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 +1846,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 +1871,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 +1923,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 +1945,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 +1967,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..bc63126d6 100644 --- a/test/http_client_transport_tests.jl +++ b/test/http_client_transport_tests.jl @@ -65,9 +65,7 @@ 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 @@ -77,161 +75,6 @@ function _transport_debug(msg::AbstractString) 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 - 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) - 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!() - 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 - 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) @@ -264,9 +107,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 @@ -584,7 +424,6 @@ 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 @test String(_read_all_transport_body_bytes(res1.body)) == "first" @@ -615,7 +454,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 +486,6 @@ 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 first_byte = Vector{UInt8}(undef, 1) @test HT.body_read!(res1.body, first_byte) == 1 @@ -680,7 +517,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 +538,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 +591,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 +611,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 +639,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 +663,7 @@ end end, () -> begin close_count[] += 1 + notify(body_closed) return nothing end, ) @@ -831,6 +671,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 +682,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 +735,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 +758,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 +818,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 +859,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 +913,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 +923,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 +945,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 +984,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..269f9f95e 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) 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..711a3c241 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 @@ -463,8 +270,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 +287,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 +325,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 +342,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 +376,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 +418,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 +488,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 +575,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 +741,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 +804,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 +840,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 +854,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 +879,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 +903,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 +914,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 +933,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 +947,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 +956,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 +969,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 +989,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 +1010,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 +1031,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 +1039,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 +1060,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 +1071,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 +1118,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 +1147,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 +1177,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 +1204,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 +1226,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 +1247,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 +1272,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 +1310,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 +1333,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 +1364,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 +1384,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 +1409,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 +1442,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 +1450,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..617883725 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 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 From eca220c48ba3091ce9674dba7725fb9e9d9acf8a Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Thu, 6 Aug 2026 10:59:07 -0600 Subject: [PATCH 2/2] test(ci): restore deterministic wait coverage Add lifecycle barriers and private clock and wait seams so deterministic tests still execute active wait, wake, handoff, and shutdown paths. Restore project coverage without reintroducing elapsed-time coordination. --- src/http2_client.jl | 12 ++++-- src/http2_server.jl | 11 ++++-- src/http_transport.jl | 18 +++++++-- test/http2_client_tests.jl | 54 ++++++++++++++++++++++++++ test/http2_server_tests.jl | 15 ++++++++ test/http_client_transport_tests.jl | 59 +++++++++++++++++++++++++++++ test/http_core_tests.jl | 9 +++++ test/http_server_http1_tests.jl | 3 ++ test/http_websocket_server_tests.jl | 13 ++++++- 9 files changed, 182 insertions(+), 12 deletions(-) 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_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/http2_client_tests.jl b/test/http2_client_tests.jl index 6df84cb08..3a9607012 100644 --- a/test/http2_client_tests.jl +++ b/test/http2_client_tests.jl @@ -2408,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 diff --git a/test/http2_server_tests.jl b/test/http2_server_tests.jl index f99663717..2d92fb6ad 100644 --- a/test/http2_server_tests.jl +++ b/test/http2_server_tests.jl @@ -1744,6 +1744,21 @@ end unlock(send_state.state_lock) end @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 waited == Int64[6] end @testset "HTTP/2 server keeps the connection usable after stream resets" begin diff --git a/test/http_client_transport_tests.jl b/test/http_client_transport_tests.jl index bc63126d6..09deabc90 100644 --- a/test/http_client_transport_tests.jl +++ b/test/http_client_transport_tests.jl @@ -70,6 +70,18 @@ function _wait_task!(task::Task) return nothing end +function _wait_for_transport_waiter!(transport::HT.Transport, key::String)::Nothing + lock(transport.lock) + try + while isempty(get(() -> HT._ConnWaiter[], transport.waiters, key)) + wait(transport.waiter_condition) + end + finally + unlock(transport.lock) + end + return nothing +end + function _transport_debug(msg::AbstractString) _ = msg return nothing @@ -86,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 @@ -152,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 @@ -425,6 +480,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)) + _wait_for_transport_waiter!(transport, "http://$address") + @test String(_read_all_transport_body_bytes(res1.body)) == "first" res2 = fetch(res2_task) @@ -487,6 +544,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)) + _wait_for_transport_waiter!(transport, "http://$address") + first_byte = Vector{UInt8}(undef, 1) @test HT.body_read!(res1.body, first_byte) == 1 HT.body_close!(res1.body) diff --git a/test/http_core_tests.jl b/test/http_core_tests.jl index 269f9f95e..e643dccdd 100644 --- a/test/http_core_tests.jl +++ b/test/http_core_tests.jl @@ -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_server_http1_tests.jl b/test/http_server_http1_tests.jl index 711a3c241..b1d90778f 100644 --- a/test/http_server_http1_tests.jl +++ b/test/http_server_http1_tests.jl @@ -238,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, diff --git a/test/http_websocket_server_tests.jl b/test/http_websocket_server_tests.jl index 617883725..7f2ac4f2d 100644 --- a/test/http_websocket_server_tests.jl +++ b/test/http_websocket_server_tests.jl @@ -319,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 @@ -328,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 @@ -347,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