|
52 | 52 | # indefinitely. Wrap the whole exchange in `_run_with_timeout`, a task-level |
53 | 53 | # watchdog that does not depend on socket deadlines, so a stuck read fails the |
54 | 54 | # test in seconds instead of hanging the job until CI's wall-clock limit. |
| 55 | +# Raised when the raw probe saw no response bytes at all within its budget. |
| 56 | +# On Windows this is the known IOCP wake strand (JuliaServices/Reseau.jl#107): |
| 57 | +# the parked reader is intermittently never woken even though bytes arrive. |
| 58 | +struct _FirstByteTimeout <: Exception end |
| 59 | + |
| 60 | +# `_read_until_quiet` runs under its own `_run_with_timeout` watchdog, so the |
| 61 | +# timeout surfaces here wrapped in (possibly nested) TaskFailedException. |
| 62 | +function _is_first_byte_timeout(err)::Bool |
| 63 | + err isa _FirstByteTimeout && return true |
| 64 | + err isa TaskFailedException || return false |
| 65 | + inner = err.task.exception |
| 66 | + return inner !== nothing && _is_first_byte_timeout(inner) |
| 67 | +end |
| 68 | + |
55 | 69 | function _raw_http_request( |
56 | 70 | port::Integer, |
57 | 71 | request::AbstractString; |
58 | 72 | settle_s::Float64 = 0.5, |
59 | 73 | close_write::Bool = true, |
60 | 74 | wait_for_first_byte::Bool = true, |
| 75 | + # Re-dial fresh and retry when no first byte arrives. Windows-only by |
| 76 | + # default so other platforms stay strict; safe for these probes because no |
| 77 | + # caller asserts on server-side invocation counts. |
| 78 | + attempts::Int = Sys.iswindows() ? 3 : 1, |
61 | 79 | )::String |
62 | | - return _run_with_timeout(; timeout_s = max(8.0, settle_s + 4.0), label = "raw http request (port $(port))") do |
63 | | - sock = ND.connect("tcp", "127.0.0.1:$(Int(port))") |
64 | | - try |
65 | | - write(sock, Vector{UInt8}(codeunits(String(request)))) |
66 | | - if close_write |
67 | | - HT.@try_ignore begin |
68 | | - NC.closewrite(sock) |
| 80 | + return _run_with_timeout(; timeout_s = max(8.0, settle_s + 4.0) * attempts, label = "raw http request (port $(port))") do |
| 81 | + for attempt in 1:attempts |
| 82 | + sock = ND.connect("tcp", "127.0.0.1:$(Int(port))") |
| 83 | + try |
| 84 | + write(sock, Vector{UInt8}(codeunits(String(request)))) |
| 85 | + if close_write |
| 86 | + HT.@try_ignore begin |
| 87 | + NC.closewrite(sock) |
| 88 | + end |
69 | 89 | end |
| 90 | + # master hardening kept: longer first-byte budget on Windows, |
| 91 | + # combined with the re-dial retry below |
| 92 | + first_byte_timeout_s = max(Sys.iswindows() ? 5.0 : 2.0, settle_s + 1.0) |
| 93 | + return _read_until_quiet( |
| 94 | + sock; |
| 95 | + timeout_s = first_byte_timeout_s, |
| 96 | + quiet_timeout_s = min(0.25, max(0.05, settle_s)), |
| 97 | + wait_for_first_byte = wait_for_first_byte, |
| 98 | + ) |
| 99 | + catch err |
| 100 | + _is_first_byte_timeout(err) || rethrow(err) |
| 101 | + attempt < attempts || error("timed out waiting for first response byte ($(attempts) attempts)") |
| 102 | + @warn "raw probe got no first byte; re-dialing — known Windows IOCP wake strand (Reseau#107)" attempt port |
| 103 | + finally |
| 104 | + NC.close(sock) |
70 | 105 | end |
71 | | - first_byte_timeout_s = max(Sys.iswindows() ? 5.0 : 2.0, settle_s + 1.0) |
72 | | - return _read_until_quiet( |
73 | | - sock; |
74 | | - timeout_s = first_byte_timeout_s, |
75 | | - quiet_timeout_s = min(0.25, max(0.05, settle_s)), |
76 | | - wait_for_first_byte = wait_for_first_byte, |
77 | | - ) |
78 | | - finally |
79 | | - NC.close(sock) |
80 | 106 | end |
| 107 | + error("unreachable: raw probe retry loop exited") |
81 | 108 | end |
82 | 109 | end |
83 | 110 |
|
@@ -166,7 +193,7 @@ function _read_until_quiet( |
166 | 193 | end |
167 | 194 | end |
168 | 195 | if wait_for_first_byte && !saw_bytes |
169 | | - error("timed out waiting for first response byte") |
| 196 | + throw(_FirstByteTimeout()) |
170 | 197 | end |
171 | 198 | return String(out) |
172 | 199 | end |
|
0 commit comments