|
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 | + return _read_until_quiet( |
| 91 | + sock; |
| 92 | + timeout_s = max(2.0, settle_s + 1.0), |
| 93 | + quiet_timeout_s = min(0.25, max(0.05, settle_s)), |
| 94 | + wait_for_first_byte = wait_for_first_byte, |
| 95 | + ) |
| 96 | + catch err |
| 97 | + _is_first_byte_timeout(err) || rethrow(err) |
| 98 | + attempt < attempts || error("timed out waiting for first response byte ($(attempts) attempts)") |
| 99 | + @warn "raw probe got no first byte; re-dialing — known Windows IOCP wake strand (Reseau#107)" attempt port |
| 100 | + finally |
| 101 | + NC.close(sock) |
70 | 102 | end |
71 | | - return _read_until_quiet( |
72 | | - sock; |
73 | | - timeout_s = max(2.0, settle_s + 1.0), |
74 | | - quiet_timeout_s = min(0.25, max(0.05, settle_s)), |
75 | | - wait_for_first_byte = wait_for_first_byte, |
76 | | - ) |
77 | | - finally |
78 | | - NC.close(sock) |
79 | 103 | end |
| 104 | + error("unreachable: raw probe retry loop exited") |
80 | 105 | end |
81 | 106 | end |
82 | 107 |
|
@@ -165,7 +190,7 @@ function _read_until_quiet( |
165 | 190 | end |
166 | 191 | end |
167 | 192 | if wait_for_first_byte && !saw_bytes |
168 | | - error("timed out waiting for first response byte") |
| 193 | + throw(_FirstByteTimeout()) |
169 | 194 | end |
170 | 195 | return String(out) |
171 | 196 | end |
|
0 commit comments