honeymaker 0.12.3 → 0.12.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/native-gems.yml +100 -0
- data/.github/workflows/release.yml +36 -0
- data/.github/workflows/test.yml +19 -4
- data/Cargo.lock +1078 -0
- data/Cargo.toml +14 -0
- data/README.md +39 -0
- data/Rakefile +28 -16
- data/crates/honeymaker/Cargo.toml +18 -0
- data/crates/honeymaker/src/encode.rs +106 -0
- data/crates/honeymaker/src/kraken/lookup.rs +140 -0
- data/crates/honeymaker/src/kraken/mod.rs +5 -0
- data/crates/honeymaker/src/kraken/normalize.rs +536 -0
- data/crates/honeymaker/src/kraken/requests.rs +286 -0
- data/crates/honeymaker/src/kraken/sign.rs +166 -0
- data/crates/honeymaker/src/kraken/trades.rs +201 -0
- data/crates/honeymaker/src/lib.rs +10 -0
- data/crates/honeymaker/src/num.rs +115 -0
- data/crates/honeymaker/src/semantics.rs +100 -0
- data/crates/honeymaker/src/types.rs +85 -0
- data/crates/honeymaker/tests/kraken_stage2.rs +592 -0
- data/crates/honeymaker/tests/kraken_trades_pager.rs +307 -0
- data/crates/honeymaker/tests/kraken_vectors.rs +78 -0
- data/crates/honeymaker/tests/vectors/decimal.json +146 -0
- data/crates/honeymaker/tests/vectors/kraken_add_order_wire.json +74 -0
- data/crates/honeymaker/tests/vectors/kraken_signing.json +3314 -0
- data/crates/honeymaker/tests/vectors/kraken_trades_pager.json +1583 -0
- data/crates/honeymaker-client/Cargo.toml +30 -0
- data/crates/honeymaker-client/examples/probe.rs +147 -0
- data/crates/honeymaker-client/src/kraken.rs +514 -0
- data/crates/honeymaker-client/src/lib.rs +4 -0
- data/crates/honeymaker-client/src/transport/connect.rs +583 -0
- data/crates/honeymaker-client/src/transport/io_timeout.rs +352 -0
- data/crates/honeymaker-client/src/transport/response.rs +169 -0
- data/crates/honeymaker-client/src/transport.rs +467 -0
- data/crates/honeymaker-client/tests/classification.rs +203 -0
- data/crates/honeymaker-client/tests/kraken_client.rs +949 -0
- data/crates/honeymaker-client/tests/response.rs +522 -0
- data/crates/honeymaker-client/tests/support/mod.rs +550 -0
- data/crates/honeymaker-client/tests/transport.rs +828 -0
- data/ext/honeymaker_native/Cargo.toml +16 -0
- data/ext/honeymaker_native/extconf.rb +6 -0
- data/ext/honeymaker_native/src/convert.rs +150 -0
- data/ext/honeymaker_native/src/kraken.rs +362 -0
- data/ext/honeymaker_native/src/lib.rs +90 -0
- data/ext/honeymaker_native/src/ruby_decimal.rs +90 -0
- data/lib/honeymaker/clients/bybit.rb +19 -2
- data/lib/honeymaker/clients/coinbase.rb +3 -2
- data/lib/honeymaker/clients/kraken.rb +6 -1
- data/lib/honeymaker/exchanges/coinbase.rb +5 -4
- data/lib/honeymaker/native/kraken.rb +263 -0
- data/lib/honeymaker/native.rb +42 -0
- data/lib/honeymaker/version.rb +1 -1
- data/lib/honeymaker.rb +12 -2
- data/script/parity/kraken_client_live.rb +191 -0
- data/script/parity/kraken_live.rb +70 -0
- data/script/parity/kraken_transport.rb +604 -0
- data/script/parity/resolve.c +49 -0
- data/script/release/push_gems.sh +28 -0
- data/script/vectors/kraken.rb +50 -0
- data/script/vectors/kraken_stage2.rb +146 -0
- data/test/fixtures/coinbase_products.json +1 -1
- data/test/honeymaker/clients/bybit_client_test.rb +25 -0
- data/test/honeymaker/clients/coinbase_client_test.rb +14 -0
- data/test/honeymaker/clients/kraken_client_test.rb +15 -0
- data/test/honeymaker/exchanges/coinbase_test.rb +22 -2
- data/test/native/backend_switch_test.rb +53 -0
- data/test/native/convert_test.rb +137 -0
- data/test/native/gem_isolation_test.rb +17 -0
- data/test/native/loader_test.rb +29 -0
- data/test/native/lockstep_test.rb +23 -0
- data/test/native/signing_smoke_test.rb +28 -0
- data/test/parity/kraken_client_parity_test.rb +398 -0
- data/test/parity/kraken_exchange_parity_test.rb +172 -0
- data/test/release/branch_hardening_test.rb +34 -0
- data/test/release/push_gems_test.rb +79 -0
- data/test/smoke.rb +24 -0
- data/test/support/kraken_signing_smoke.rb +21 -0
- data/test/support/parity_helper.rb +62 -0
- data/test/support/venue_double.rb +100 -0
- metadata +84 -4
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
//! The connect phases, each bounded by the open timeout and each failing with its own cause:
|
|
2
|
+
//! resolve + TCP (target, or proxy), proxy CONNECT (Ruling R2), TLS handshake. The read/write
|
|
3
|
+
//! timeouts wrap the raw socket straight after connect, so they measure socket waits under TLS.
|
|
4
|
+
use super::io_timeout::{RequestIo, RequestProgress, Stall, TimeoutIo};
|
|
5
|
+
use super::{Cause, Failure, Proxy, Transport};
|
|
6
|
+
use std::{
|
|
7
|
+
future::{Future, poll_fn},
|
|
8
|
+
io,
|
|
9
|
+
net::SocketAddr,
|
|
10
|
+
pin::Pin,
|
|
11
|
+
sync::Arc,
|
|
12
|
+
task::Poll,
|
|
13
|
+
time::Duration,
|
|
14
|
+
};
|
|
15
|
+
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
|
16
|
+
use tokio::net::{TcpStream, lookup_host};
|
|
17
|
+
use tokio::time::{Instant, sleep_until, timeout, timeout_at};
|
|
18
|
+
use tokio_rustls::TlsConnector;
|
|
19
|
+
|
|
20
|
+
pub(crate) trait Io: AsyncRead + AsyncWrite + Send + Unpin {}
|
|
21
|
+
impl<T: AsyncRead + AsyncWrite + Send + Unpin> Io for T {}
|
|
22
|
+
|
|
23
|
+
pub(crate) async fn connect(
|
|
24
|
+
t: &Transport,
|
|
25
|
+
progress: Arc<RequestProgress>,
|
|
26
|
+
) -> Result<Box<dyn Io>, Failure> {
|
|
27
|
+
let (host, port) = match &t.proxy {
|
|
28
|
+
Some(p) => (p.host.as_str(), p.port),
|
|
29
|
+
None => (t.target.host.as_str(), t.target.port),
|
|
30
|
+
};
|
|
31
|
+
let deadline = Instant::now() + t.timeouts.open;
|
|
32
|
+
let tcp = open_tcp(host, port, deadline, tcp(host, port, deadline)).await?;
|
|
33
|
+
let mut tcp = TimeoutIo::for_request(tcp, t.timeouts.read, t.timeouts.write, progress.clone());
|
|
34
|
+
if let Some(p) = &t.proxy {
|
|
35
|
+
let connect_timeout =
|
|
36
|
+
|| Failure::new(Cause::OpenTimeout, "Net::OpenTimeout (proxy CONNECT)");
|
|
37
|
+
timeout(t.timeouts.open, tunnel(&mut tcp, p, &t.target.authority()))
|
|
38
|
+
.await
|
|
39
|
+
.map_err(|_| connect_timeout())?
|
|
40
|
+
.map_err(|f| if is_stall(&f) { connect_timeout() } else { f })?;
|
|
41
|
+
}
|
|
42
|
+
if !t.target.tls {
|
|
43
|
+
return Ok(Box::new(RequestIo::new(tcp, progress)));
|
|
44
|
+
}
|
|
45
|
+
let tls = timeout(
|
|
46
|
+
t.timeouts.open,
|
|
47
|
+
TlsConnector::from(t.tls.clone()).connect(t.server_name.clone(), tcp),
|
|
48
|
+
)
|
|
49
|
+
.await
|
|
50
|
+
.map_err(|_| Failure::new(Cause::OpenTimeout, "Net::OpenTimeout"))?
|
|
51
|
+
.map_err(|e| handshake_failure(&e))?;
|
|
52
|
+
Ok(Box::new(RequestIo::new(tls, progress)))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async fn open_tcp<T>(
|
|
56
|
+
host: &str,
|
|
57
|
+
port: u16,
|
|
58
|
+
deadline: Instant,
|
|
59
|
+
connect: impl std::future::Future<Output = Result<T, Failure>>,
|
|
60
|
+
) -> Result<T, Failure> {
|
|
61
|
+
timeout_at(deadline, connect).await.map_err(|_| {
|
|
62
|
+
Failure::new(Cause::OpenTimeout,
|
|
63
|
+
format!("Failed to open TCP connection to {host}:{port} (user specified timeout for {host}:{port})"))
|
|
64
|
+
})?
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async fn tcp(host: &str, port: u16, deadline: Instant) -> Result<TcpStream, Failure> {
|
|
68
|
+
let addrs: Vec<_> = lookup_host((host, port))
|
|
69
|
+
.await
|
|
70
|
+
.map_err(|e| {
|
|
71
|
+
Failure::new(
|
|
72
|
+
Cause::Dns,
|
|
73
|
+
format!("Failed to open TCP connection to {host}:{port} (getaddrinfo: {e})"),
|
|
74
|
+
)
|
|
75
|
+
})?
|
|
76
|
+
.collect();
|
|
77
|
+
if addrs.is_empty() {
|
|
78
|
+
return Err(Failure::new(
|
|
79
|
+
Cause::Dns,
|
|
80
|
+
format!("Failed to open TCP connection to {host}:{port} (no address)"),
|
|
81
|
+
));
|
|
82
|
+
}
|
|
83
|
+
connect_any(&addrs, deadline)
|
|
84
|
+
.await
|
|
85
|
+
.map_err(|e| tcp_failure(host, port, &e))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async fn connect_any(addrs: &[SocketAddr], deadline: Instant) -> io::Result<TcpStream> {
|
|
89
|
+
connect_any_with(addrs, deadline, TcpStream::connect).await
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async fn connect_any_with<T, F: Future<Output = io::Result<T>>>(
|
|
93
|
+
addrs: &[SocketAddr],
|
|
94
|
+
deadline: Instant,
|
|
95
|
+
mut connector: impl FnMut(SocketAddr) -> F,
|
|
96
|
+
) -> io::Result<T> {
|
|
97
|
+
// All attempts belong to this future: returning a winner or timing out drops the rest.
|
|
98
|
+
// Only TCP connects happen here; TLS, proxy CONNECT and request writes use the winner.
|
|
99
|
+
let mut attempts: Vec<Option<Pin<Box<F>>>> = Vec::new();
|
|
100
|
+
let mut next = 0;
|
|
101
|
+
let mut last_error = None;
|
|
102
|
+
let mut expires = Box::pin(sleep_until(deadline));
|
|
103
|
+
let mut next_attempt = Box::pin(sleep_until(Instant::now()));
|
|
104
|
+
poll_fn(|cx| {
|
|
105
|
+
loop {
|
|
106
|
+
// Check before starting or accepting an attempt, including at the deadline itself.
|
|
107
|
+
if expires.as_mut().poll(cx).is_ready() {
|
|
108
|
+
return Poll::Ready(Err(io::Error::from(io::ErrorKind::TimedOut)));
|
|
109
|
+
}
|
|
110
|
+
for attempt in &mut attempts {
|
|
111
|
+
let Some(future) = attempt else {
|
|
112
|
+
continue;
|
|
113
|
+
};
|
|
114
|
+
if let Poll::Ready(result) = future.as_mut().poll(cx) {
|
|
115
|
+
*attempt = None;
|
|
116
|
+
match result {
|
|
117
|
+
Ok(stream) => return Poll::Ready(Ok(stream)),
|
|
118
|
+
Err(error) => {
|
|
119
|
+
last_error = Some(error);
|
|
120
|
+
// RFC 8305 §5 / Ruby: a failure starts the next address at once.
|
|
121
|
+
next_attempt.as_mut().reset(Instant::now());
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if next < addrs.len() && next_attempt.as_mut().poll(cx).is_ready() {
|
|
127
|
+
attempts.push(Some(Box::pin(connector(addrs[next]))));
|
|
128
|
+
next += 1;
|
|
129
|
+
next_attempt
|
|
130
|
+
.as_mut()
|
|
131
|
+
.reset(Instant::now() + Duration::from_millis(250));
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if next == addrs.len() && attempts.iter().all(Option::is_none) {
|
|
135
|
+
return Poll::Ready(Err(last_error.take().unwrap_or_else(|| {
|
|
136
|
+
io::Error::new(io::ErrorKind::InvalidInput, "no address")
|
|
137
|
+
})));
|
|
138
|
+
}
|
|
139
|
+
return Poll::Pending;
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
.await
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
fn tcp_failure(host: &str, port: u16, e: &io::Error) -> Failure {
|
|
146
|
+
use io::ErrorKind::*;
|
|
147
|
+
match e.kind() {
|
|
148
|
+
ConnectionRefused => {
|
|
149
|
+
Failure::new(Cause::Refused, format!("connection refused: {host}:{port}"))
|
|
150
|
+
}
|
|
151
|
+
TimedOut => Failure::new(
|
|
152
|
+
Cause::OpenTimeout,
|
|
153
|
+
format!("Failed to open TCP connection to {host}:{port} ({e})"),
|
|
154
|
+
),
|
|
155
|
+
HostUnreachable | NetworkUnreachable | NetworkDown | AddrNotAvailable => Failure::new(
|
|
156
|
+
Cause::Unreachable,
|
|
157
|
+
format!("Failed to open TCP connection to {host}:{port} ({e})"),
|
|
158
|
+
),
|
|
159
|
+
// EHOSTDOWN lands here (std has no kind for it): Rails lists it as pre-transmission, but
|
|
160
|
+
// Ambiguous is the safe side, so it is not mapped to Unreachable.
|
|
161
|
+
_ => Failure::new(
|
|
162
|
+
Cause::Other,
|
|
163
|
+
format!("Failed to open TCP connection to {host}:{port} ({e})"),
|
|
164
|
+
),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/// HTTP CONNECT as Net::HTTP sends it; the answer's status line decides.
|
|
169
|
+
/// A socket-wait timeout before any request byte was written is the open phase's timeout (R21).
|
|
170
|
+
fn is_stall(f: &Failure) -> bool {
|
|
171
|
+
matches!(f.cause, Cause::ReadTimeout | Cause::WriteTimeout)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async fn tunnel<S: AsyncRead + AsyncWrite + Unpin>(
|
|
175
|
+
s: &mut S,
|
|
176
|
+
p: &Proxy,
|
|
177
|
+
authority: &str,
|
|
178
|
+
) -> Result<(), Failure> {
|
|
179
|
+
let mut req = format!("CONNECT {authority} HTTP/1.1\r\nHost: {authority}\r\n");
|
|
180
|
+
if let Some(a) = &p.authorization {
|
|
181
|
+
req.push_str(&format!("Proxy-Authorization: {a}\r\n"));
|
|
182
|
+
}
|
|
183
|
+
req.push_str("\r\n");
|
|
184
|
+
s.write_all(req.as_bytes())
|
|
185
|
+
.await
|
|
186
|
+
.map_err(|e| io_failure(&e))?;
|
|
187
|
+
// Read one byte at a time so no tunnel byte is buffered past the head; the head is tiny.
|
|
188
|
+
let mut head = Vec::new();
|
|
189
|
+
while !head.ends_with(b"\r\n\r\n") {
|
|
190
|
+
if head.len() >= 16 * 1024 {
|
|
191
|
+
return Err(Failure::new(
|
|
192
|
+
Cause::ProxyFailed,
|
|
193
|
+
"proxy CONNECT answer too large",
|
|
194
|
+
));
|
|
195
|
+
}
|
|
196
|
+
let mut b = [0u8; 1];
|
|
197
|
+
match s.read(&mut b).await {
|
|
198
|
+
Ok(0) => return Err(Failure::new(Cause::Eof, "end of file reached")),
|
|
199
|
+
Ok(_) => head.push(b[0]),
|
|
200
|
+
Err(e) => return Err(io_failure(&e)),
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
let text = String::from_utf8_lossy(&head);
|
|
204
|
+
let line = text.lines().next().unwrap_or("");
|
|
205
|
+
let mut parts = line.splitn(3, ' ');
|
|
206
|
+
let (version, code, reason) = (parts.next(), parts.next(), parts.next().unwrap_or(""));
|
|
207
|
+
if !matches!(version, Some("HTTP/1.0" | "HTTP/1.1"))
|
|
208
|
+
|| !code.is_some_and(|c| c.len() == 3 && c.bytes().all(|b| b.is_ascii_digit()))
|
|
209
|
+
{
|
|
210
|
+
return Err(Failure::new(
|
|
211
|
+
Cause::ProxyFailed,
|
|
212
|
+
format!("wrong status line: {line:?}"),
|
|
213
|
+
));
|
|
214
|
+
}
|
|
215
|
+
for header in text.split("\r\n").skip(1).take_while(|l| !l.is_empty()) {
|
|
216
|
+
let valid = header.split_once(':').is_some_and(|(name, value)| {
|
|
217
|
+
hyper::header::HeaderName::from_bytes(name.as_bytes()).is_ok()
|
|
218
|
+
&& hyper::header::HeaderValue::from_str(value.trim()).is_ok()
|
|
219
|
+
});
|
|
220
|
+
if !valid {
|
|
221
|
+
return Err(Failure::new(
|
|
222
|
+
Cause::ProxyFailed,
|
|
223
|
+
"malformed proxy CONNECT headers",
|
|
224
|
+
));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
let code = code.and_then(|c| c.parse::<u16>().ok());
|
|
228
|
+
match code {
|
|
229
|
+
Some(c) if (200..300).contains(&c) => Ok(()),
|
|
230
|
+
// Net::HTTPResponse#value: 4xx is HTTPClientException; its message is `code "reason"`.
|
|
231
|
+
Some(c) if (400..500).contains(&c) => Err(Failure::new(
|
|
232
|
+
Cause::ProxyRefused(c),
|
|
233
|
+
format!("{c} {reason:?}"),
|
|
234
|
+
)),
|
|
235
|
+
Some(c) => Err(Failure::new(Cause::ProxyFailed, format!("{c} {reason:?}"))),
|
|
236
|
+
None => Err(Failure::new(
|
|
237
|
+
Cause::ProxyFailed,
|
|
238
|
+
format!("wrong status line: {line:?}"),
|
|
239
|
+
)),
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/// A socket error after the connection is up.
|
|
244
|
+
pub(crate) fn io_failure(e: &io::Error) -> Failure {
|
|
245
|
+
if let Some(stall) = e.get_ref().and_then(|x| x.downcast_ref::<Stall>()) {
|
|
246
|
+
return match stall {
|
|
247
|
+
Stall::Read => Failure::new(Cause::ReadTimeout, "Net::ReadTimeout"),
|
|
248
|
+
Stall::Write => Failure::new(Cause::WriteTimeout, "Net::WriteTimeout"),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
match e.kind() {
|
|
252
|
+
io::ErrorKind::UnexpectedEof => Failure::new(Cause::Eof, "end of file reached"),
|
|
253
|
+
io::ErrorKind::ConnectionReset => {
|
|
254
|
+
Failure::new(Cause::Reset, format!("Connection reset by peer ({e})"))
|
|
255
|
+
}
|
|
256
|
+
io::ErrorKind::InvalidData if e.get_ref().is_some_and(|x| x.is::<rustls::Error>()) => {
|
|
257
|
+
Failure::new(Cause::Tls, format!("SSL error: {e}"))
|
|
258
|
+
}
|
|
259
|
+
_ => Failure::new(Cause::Other, e.to_string()),
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/// Any TLS handshake failure other than its timeout is Cause::Tls (Ruby: OpenSSL::SSL::SSLError).
|
|
264
|
+
fn handshake_failure(e: &io::Error) -> Failure {
|
|
265
|
+
match io_failure(e) {
|
|
266
|
+
f if is_stall(&f) => Failure::new(Cause::OpenTimeout, "Net::OpenTimeout"),
|
|
267
|
+
f if f.cause == Cause::Reset => f,
|
|
268
|
+
f => Failure::new(Cause::Tls, format!("SSL_connect: {}", f.message)),
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/// Never let the legacy message fallback turn a post-send socket failure into
|
|
273
|
+
/// permission to retry. The exception's kind is retained without its misleading text.
|
|
274
|
+
fn exchange_io_failure(e: &io::Error) -> Failure {
|
|
275
|
+
let failure = io_failure(e);
|
|
276
|
+
if failure.pre_transmission() {
|
|
277
|
+
Failure::new(
|
|
278
|
+
Cause::Other,
|
|
279
|
+
format!("request exchange I/O error ({:?})", e.kind()),
|
|
280
|
+
)
|
|
281
|
+
} else {
|
|
282
|
+
failure
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/// A failure hyper reports during the exchange: the first io::Error in its source chain decides,
|
|
287
|
+
/// else an incomplete message is end-of-file. All of these are post-send, hence ambiguous.
|
|
288
|
+
pub(crate) fn hyper_failure(e: &hyper::Error) -> Failure {
|
|
289
|
+
let mut src: Option<&(dyn std::error::Error + 'static)> = Some(e);
|
|
290
|
+
while let Some(s) = src {
|
|
291
|
+
if let Some(io) = s.downcast_ref::<io::Error>() {
|
|
292
|
+
return exchange_io_failure(io);
|
|
293
|
+
}
|
|
294
|
+
src = s.source();
|
|
295
|
+
}
|
|
296
|
+
if e.is_incomplete_message() {
|
|
297
|
+
return Failure::new(Cause::Eof, "end of file reached");
|
|
298
|
+
}
|
|
299
|
+
Failure::new(Cause::Other, e.to_string())
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
#[cfg(test)]
|
|
303
|
+
mod tests {
|
|
304
|
+
use super::*;
|
|
305
|
+
use std::time::Duration;
|
|
306
|
+
|
|
307
|
+
use crate::kraken::{VenueError, outcome};
|
|
308
|
+
use std::cell::{Cell, RefCell};
|
|
309
|
+
use std::rc::Rc;
|
|
310
|
+
use tokio::net::TcpListener;
|
|
311
|
+
|
|
312
|
+
fn addresses() -> [SocketAddr; 3] {
|
|
313
|
+
[1, 2, 3].map(|port| SocketAddr::from(([127, 0, 0, 1], port)))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
fn refused_address() -> SocketAddr {
|
|
317
|
+
// Drop a listening socket: a merely bound socket can black-hole SYNs on macOS.
|
|
318
|
+
std::net::TcpListener::bind("127.0.0.1:0")
|
|
319
|
+
.unwrap()
|
|
320
|
+
.local_addr()
|
|
321
|
+
.unwrap()
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
struct Dropped(Rc<Cell<usize>>);
|
|
325
|
+
impl Drop for Dropped {
|
|
326
|
+
fn drop(&mut self) {
|
|
327
|
+
self.0.set(self.0.get() + 1);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
#[tokio::test]
|
|
332
|
+
async fn a_refused_address_falls_back_without_writing_any_bytes() {
|
|
333
|
+
let refused = refused_address();
|
|
334
|
+
let live = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
335
|
+
let addrs = [refused, live.local_addr().unwrap()];
|
|
336
|
+
let stream = connect_any(&addrs, Instant::now() + Duration::from_secs(2))
|
|
337
|
+
.await
|
|
338
|
+
.unwrap();
|
|
339
|
+
assert_eq!(stream.peer_addr().unwrap(), addrs[1]);
|
|
340
|
+
let (mut accepted, _) = live.accept().await.unwrap();
|
|
341
|
+
drop(stream);
|
|
342
|
+
assert_eq!(accepted.read(&mut [0; 1]).await.unwrap(), 0);
|
|
343
|
+
assert!(
|
|
344
|
+
timeout(Duration::from_millis(30), live.accept())
|
|
345
|
+
.await
|
|
346
|
+
.is_err()
|
|
347
|
+
);
|
|
348
|
+
// The refused socket never establishes a stream, so it cannot receive request bytes.
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
#[tokio::test]
|
|
352
|
+
async fn a_stalled_address_does_not_block_a_live_listener() {
|
|
353
|
+
let live = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
354
|
+
let addrs = [addresses()[0], live.local_addr().unwrap()];
|
|
355
|
+
let started = Instant::now();
|
|
356
|
+
let stream = connect_any_with(
|
|
357
|
+
&addrs,
|
|
358
|
+
started + Duration::from_secs(2),
|
|
359
|
+
|addr| async move {
|
|
360
|
+
if addr == addrs[0] {
|
|
361
|
+
std::future::pending::<()>().await;
|
|
362
|
+
}
|
|
363
|
+
TcpStream::connect(addr).await
|
|
364
|
+
},
|
|
365
|
+
)
|
|
366
|
+
.await
|
|
367
|
+
.unwrap();
|
|
368
|
+
assert!(started.elapsed() >= Duration::from_millis(250));
|
|
369
|
+
assert!(started.elapsed() < Duration::from_secs(1));
|
|
370
|
+
let (mut accepted, _) = live.accept().await.unwrap();
|
|
371
|
+
drop(stream);
|
|
372
|
+
assert_eq!(accepted.read(&mut [0; 1]).await.unwrap(), 0);
|
|
373
|
+
assert!(
|
|
374
|
+
timeout(Duration::from_millis(30), live.accept())
|
|
375
|
+
.await
|
|
376
|
+
.is_err()
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
#[tokio::test(start_paused = true)]
|
|
381
|
+
async fn attempts_are_staggered_and_the_first_success_drops_pending_attempts() {
|
|
382
|
+
for winner in [1, 2] {
|
|
383
|
+
let addrs = addresses();
|
|
384
|
+
let started = Instant::now();
|
|
385
|
+
let starts = RefCell::new(Vec::new());
|
|
386
|
+
let dropped = Rc::new(Cell::new(0));
|
|
387
|
+
let connected = connect_any_with(&addrs, started + Duration::from_secs(2), |addr| {
|
|
388
|
+
starts.borrow_mut().push((addr, started.elapsed()));
|
|
389
|
+
let guard = Dropped(dropped.clone());
|
|
390
|
+
async move {
|
|
391
|
+
let _guard = guard;
|
|
392
|
+
if addr != addrs[winner] {
|
|
393
|
+
std::future::pending::<()>().await;
|
|
394
|
+
}
|
|
395
|
+
Ok(addr)
|
|
396
|
+
}
|
|
397
|
+
})
|
|
398
|
+
.await
|
|
399
|
+
.unwrap();
|
|
400
|
+
assert_eq!(connected, addrs[winner]);
|
|
401
|
+
assert_eq!(
|
|
402
|
+
started.elapsed(),
|
|
403
|
+
Duration::from_millis(250 * winner as u64)
|
|
404
|
+
);
|
|
405
|
+
assert_eq!(
|
|
406
|
+
*starts.borrow(),
|
|
407
|
+
addrs[..=winner]
|
|
408
|
+
.iter()
|
|
409
|
+
.enumerate()
|
|
410
|
+
.map(|(i, addr)| (*addr, Duration::from_millis(250 * i as u64)))
|
|
411
|
+
.collect::<Vec<_>>()
|
|
412
|
+
);
|
|
413
|
+
assert_eq!(dropped.get(), winner + 1, "no attempt survives the winner");
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
#[tokio::test(start_paused = true)]
|
|
418
|
+
async fn a_failed_attempt_starts_the_next_address_at_once() {
|
|
419
|
+
// RFC 8305 §5 and Ruby's Happy Eyeballs: a failure does not wait out the 250 ms stagger.
|
|
420
|
+
for fail_after in [0, 100] {
|
|
421
|
+
let addrs = addresses();
|
|
422
|
+
let started = Instant::now();
|
|
423
|
+
let starts = RefCell::new(Vec::new());
|
|
424
|
+
let connected = connect_any_with(&addrs, started + Duration::from_secs(2), |addr| {
|
|
425
|
+
starts.borrow_mut().push((addr, started.elapsed()));
|
|
426
|
+
async move {
|
|
427
|
+
if addr == addrs[0] {
|
|
428
|
+
tokio::time::sleep(Duration::from_millis(fail_after)).await;
|
|
429
|
+
return Err(io::Error::from(io::ErrorKind::ConnectionRefused));
|
|
430
|
+
}
|
|
431
|
+
if addr == addrs[1] {
|
|
432
|
+
std::future::pending::<()>().await;
|
|
433
|
+
}
|
|
434
|
+
Ok(addr)
|
|
435
|
+
}
|
|
436
|
+
})
|
|
437
|
+
.await
|
|
438
|
+
.unwrap();
|
|
439
|
+
assert_eq!(connected, addrs[2]);
|
|
440
|
+
let ms = |m| Duration::from_millis(m);
|
|
441
|
+
assert_eq!(
|
|
442
|
+
*starts.borrow(),
|
|
443
|
+
vec![
|
|
444
|
+
(addrs[0], ms(0)),
|
|
445
|
+
(addrs[1], ms(fail_after)),
|
|
446
|
+
(addrs[2], ms(fail_after + 250)),
|
|
447
|
+
]
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
#[tokio::test]
|
|
453
|
+
async fn all_refused_addresses_are_attempted_and_classed_transient() {
|
|
454
|
+
let addrs = [refused_address(), refused_address()];
|
|
455
|
+
let attempted = RefCell::new(Vec::new());
|
|
456
|
+
let error = connect_any_with(&addrs, Instant::now() + Duration::from_secs(2), |addr| {
|
|
457
|
+
attempted.borrow_mut().push(addr);
|
|
458
|
+
TcpStream::connect(addr)
|
|
459
|
+
})
|
|
460
|
+
.await
|
|
461
|
+
.unwrap_err();
|
|
462
|
+
assert_eq!(*attempted.borrow(), addrs);
|
|
463
|
+
let failure = tcp_failure("localhost", addrs[0].port(), &error);
|
|
464
|
+
assert_eq!(failure.cause, Cause::Refused);
|
|
465
|
+
assert!(matches!(
|
|
466
|
+
outcome(Err(failure)),
|
|
467
|
+
Err(VenueError::Transient(_))
|
|
468
|
+
));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
#[tokio::test(start_paused = true)]
|
|
472
|
+
async fn all_failures_return_the_last_completed_error() {
|
|
473
|
+
let addrs = addresses();
|
|
474
|
+
let started = Instant::now();
|
|
475
|
+
let error = connect_any_with(
|
|
476
|
+
&addrs[..2],
|
|
477
|
+
started + Duration::from_secs(1),
|
|
478
|
+
|addr| async move {
|
|
479
|
+
let kind = if addr == addrs[0] {
|
|
480
|
+
tokio::time::sleep(Duration::from_millis(600)).await;
|
|
481
|
+
io::ErrorKind::PermissionDenied
|
|
482
|
+
} else {
|
|
483
|
+
io::ErrorKind::ConnectionRefused
|
|
484
|
+
};
|
|
485
|
+
Err::<(), _>(io::Error::from(kind))
|
|
486
|
+
},
|
|
487
|
+
)
|
|
488
|
+
.await
|
|
489
|
+
.unwrap_err();
|
|
490
|
+
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
|
491
|
+
assert_eq!(started.elapsed(), Duration::from_millis(600));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
#[tokio::test(start_paused = true)]
|
|
495
|
+
async fn resolution_and_all_attempts_share_one_open_deadline() {
|
|
496
|
+
for limit in [200, 600] {
|
|
497
|
+
let started = Instant::now();
|
|
498
|
+
let deadline = started + Duration::from_millis(limit);
|
|
499
|
+
let starts = RefCell::new(Vec::new());
|
|
500
|
+
let dropped = Rc::new(Cell::new(0));
|
|
501
|
+
let failure = open_tcp("localhost", 443, deadline, async {
|
|
502
|
+
// Deterministic stand-in for time spent resolving the host.
|
|
503
|
+
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
504
|
+
connect_any_with(&addresses(), deadline, |_| {
|
|
505
|
+
starts.borrow_mut().push(started.elapsed());
|
|
506
|
+
let guard = Dropped(dropped.clone());
|
|
507
|
+
async move {
|
|
508
|
+
let _guard = guard;
|
|
509
|
+
std::future::pending::<io::Result<()>>().await
|
|
510
|
+
}
|
|
511
|
+
})
|
|
512
|
+
.await
|
|
513
|
+
.map_err(|e| tcp_failure("localhost", 443, &e))
|
|
514
|
+
})
|
|
515
|
+
.await
|
|
516
|
+
.unwrap_err();
|
|
517
|
+
assert_eq!(failure.cause, Cause::OpenTimeout);
|
|
518
|
+
assert!(failure.pre_transmission());
|
|
519
|
+
assert_eq!(started.elapsed(), Duration::from_millis(limit));
|
|
520
|
+
let expected = if limit == 200 {
|
|
521
|
+
vec![100]
|
|
522
|
+
} else {
|
|
523
|
+
vec![100, 350]
|
|
524
|
+
};
|
|
525
|
+
assert_eq!(
|
|
526
|
+
*starts.borrow(),
|
|
527
|
+
expected
|
|
528
|
+
.into_iter()
|
|
529
|
+
.map(Duration::from_millis)
|
|
530
|
+
.collect::<Vec<_>>()
|
|
531
|
+
);
|
|
532
|
+
assert_eq!(dropped.get(), starts.borrow().len());
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
#[test]
|
|
537
|
+
fn a_kernel_connect_timeout_is_an_open_timeout_and_transient() {
|
|
538
|
+
let failure = tcp_failure("localhost", 443, &io::Error::from(io::ErrorKind::TimedOut));
|
|
539
|
+
assert_eq!(failure.cause, Cause::OpenTimeout);
|
|
540
|
+
assert!(matches!(
|
|
541
|
+
outcome(Err(failure)),
|
|
542
|
+
Err(VenueError::Transient(_))
|
|
543
|
+
));
|
|
544
|
+
// The same errno after connection establishment is still ambiguous.
|
|
545
|
+
assert!(!exchange_io_failure(&io::Error::from(io::ErrorKind::TimedOut)).pre_transmission());
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
#[test]
|
|
549
|
+
fn an_exchange_error_cannot_become_transient_from_its_message() {
|
|
550
|
+
let failure = exchange_io_failure(&io::Error::new(
|
|
551
|
+
io::ErrorKind::ConnectionRefused,
|
|
552
|
+
"connection refused",
|
|
553
|
+
));
|
|
554
|
+
assert!(!failure.pre_transmission());
|
|
555
|
+
assert_eq!(failure.cause, Cause::Other);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
#[tokio::test]
|
|
559
|
+
async fn local_resolver_rejection_is_pre_transmission() {
|
|
560
|
+
// Embedded NUL is rejected by getaddrinfo before any DNS traffic.
|
|
561
|
+
let failure = tcp("localhost\0", 443, Instant::now() + Duration::from_secs(1))
|
|
562
|
+
.await
|
|
563
|
+
.unwrap_err();
|
|
564
|
+
assert_eq!(failure.cause, Cause::Dns);
|
|
565
|
+
assert!(failure.pre_transmission());
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
#[tokio::test(start_paused = true)]
|
|
569
|
+
async fn a_stalled_connector_is_bounded_by_the_open_timeout() {
|
|
570
|
+
let started = tokio::time::Instant::now();
|
|
571
|
+
let failure = open_tcp(
|
|
572
|
+
"127.0.0.1",
|
|
573
|
+
443,
|
|
574
|
+
started + Duration::from_millis(300),
|
|
575
|
+
std::future::pending::<Result<TcpStream, Failure>>(),
|
|
576
|
+
)
|
|
577
|
+
.await
|
|
578
|
+
.unwrap_err();
|
|
579
|
+
assert_eq!(failure.cause, Cause::OpenTimeout);
|
|
580
|
+
assert!(failure.pre_transmission());
|
|
581
|
+
assert_eq!(started.elapsed(), Duration::from_millis(300));
|
|
582
|
+
}
|
|
583
|
+
}
|