Skip to content

Commit c96e67f

Browse files
committed
fix(net-network-information): Ping correlates its reply and reports a refused socket option (#2194)
sendPingCore accepted the FIRST datagram on its socket without correlating it to the request it had just sent, and every setsockopt discarded its return value, so an option the kernel rejected was silently not applied. Landed under SA-5. THE BLOCKER WAS ENVIRONMENTAL AND IS GONE. #2194 was blocked as unexercisable: "every send fails at socket creation because ping_group_range is '1 0'". This container's range is now '0 2147483647', SOCK_DGRAM/IPPROTO_ICMP opens, and a real loopback round trip succeeds -- measured before implementing anything. No raw-socket fallback was added; #1962 is untouched, as the ticket instructs. THE ACCEPTANCE CRITERION IS WRONG ON TWO OF ITS THREE FIELDS, both measured: - IDENTIFIER -- cannot be matched. On a Linux ping socket the kernel REWRITES it: probed, a request written with id 0x1234 came back as 0x94d4 (the socket's own port). .NET checks the identifier (Ping.RawSocket.cs:230) because its RAW path writes an id the kernel leaves alone; here that check would reject every reply. - SOURCE ADDRESS -- must not be matched. .NET does not check it either: it reports socketConfig.EndPoint.Address, the address it SENT to (:245). Requiring the source to equal the destination would reject the legitimate error replies that come from an intermediate router -- TimeExceeded above all, which is exactly what a caller setting a low Ttl wants. - SEQUENCE -- matched. It survives the ping socket unchanged (probed 0x5678 out, 0x5678 back). An ICMP error quotes the original request after its own header, so the sequence is read from the quoted request -- the same rule .NET follows (:166-190). The read loop keeps the ORIGINAL deadline across skips, so a busy socket cannot extend the call without bound. Ttl = 256 is the reachable option door and that is measured: PingOptions rejects only ttl <= 0, which is .NET's own bound, so 0 never reaches a socket, while 256 passes the type and IP_TTL refuses it with EINVAL. Mutations: 4, 2 caught -- and THE TWO THAT WERE NOT ARE THE INFORMATIVE ONES. - Computing the correlation and ignoring it changes nothing here, which EXPOSED A VACUOUS TEST OF MINE: a first case claimed to prove the skip by putting an unrelated echo on the wire, and it proves nothing, because a Linux ping socket is demultiplexed by the identifier the KERNEL assigned -- a reply belonging to another socket is never queued on ours. So the check is DEFENSIVE on this platform. It is kept because #1962 would add a raw-socket fallback, and a raw ICMP socket receives EVERY ICMP datagram on the host, at which point it becomes essential. The test was rewritten to assert what it actually establishes, and both it and Ping.cpp say so. - Restarting the deadline on every foreign datagram cannot be caught deterministically: observing it needs a sustained flood plus a wall-clock assertion, and under the mutation the call would HANG rather than fail -- flaky and useless, the shape #2352 and #2105 were repaired for. Gate: 17,402 run, 17,402 passed, 0 failed, 0 skipped across 38 executables (+4, in SharpRuntimeTests_Net_NetworkInformation, 63 -> 67). Built in build/ with --parallel 2. Downstream: zero System::Net::NetworkInformation::Ping sites in cna and mobile-eggbert (the four text matches are unrelated third-party enet/SDL code). docs/Migration-PingReplyCorrelation.md
1 parent dba5ace commit c96e67f

5 files changed

Lines changed: 331 additions & 6 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — `Ping` correlates its reply and reports a refused socket option (ticket #2194)
5+
6+
*2026-08-19.* `sendPingCore` accepted the first datagram on its socket without correlating it to
7+
the request, and every `setsockopt` discarded its return value, so an option the kernel rejected
8+
was silently not applied.
9+
10+
Landed under `docs/StandingApprovals.md` **SA-5**. No signature, layout or vtable change.
11+
12+
---
13+
14+
## 1. The blocker was environmental and is gone
15+
16+
#2194 was blocked as unexercisable: *"every send fails at socket creation because
17+
`ping_group_range` is `1 0`"*. This container's range is now **`0 2147483647`**, a
18+
`SOCK_DGRAM/IPPROTO_ICMP` socket opens, and a real loopback round trip succeeds. Measured before
19+
implementing anything.
20+
21+
The ticket's own instruction — *"Do not implement a raw-socket fallback to unblock it, that is
22+
#1962"* — is respected: nothing here touches socket creation.
23+
24+
## 2. The acceptance criterion is wrong on two of its three fields
25+
26+
It asks that *"a reply is accepted only when its **source**, **identifier** and **sequence** match
27+
the request"*. Measured:
28+
29+
* **Identifier — cannot be matched.** On a Linux ping socket the kernel **rewrites** the ICMP
30+
identifier. Probed directly: a request written with `id 0x1234` came back as `0x94d4`, the
31+
socket's own port. .NET checks the identifier (`Ping.RawSocket.cs:230`) because its raw-socket
32+
path writes an id the kernel leaves alone; here that check would reject **every** reply.
33+
* **Source address — must not be matched.** .NET does not check it either; it reports
34+
`socketConfig.EndPoint.Address`, the address it *sent to* (`:245`). Requiring the source to
35+
equal the destination would reject the legitimate error replies that come from an intermediate
36+
router — `TimeExceeded` above all, which is exactly what a caller setting a low `Ttl` is asking
37+
for.
38+
* **Sequence — matched.** It survives the ping socket unchanged (probed: `0x5678` out, `0x5678`
39+
back), it is ours, and it is the field that distinguishes this request from another.
40+
41+
An ICMP **error** quotes the original request after its own header, so the sequence is read from
42+
the quoted request — the same *"original IP+ICMP request is in the payload"* rule .NET follows
43+
(`Ping.RawSocket.cs:166-190`).
44+
45+
## 3. What changed
46+
47+
| | Was | Is |
48+
|---|---|---|
49+
| a datagram that does not correlate | reported as this request's answer | skipped; reading continues |
50+
| the read timeout across skips || the **original** deadline, never restarted |
51+
| a `setsockopt` the kernel refuses | silently ignored | `NetworkInformationException` |
52+
| `Ttl = 256` (legal to `PingOptions`, illegal to the kernel) | ignored, normal reply | reported |
53+
| an ordinary reply || **unchanged** |
54+
55+
`Ttl = 256` is the reachable door and that is measured, not guessed: `PingOptions` rejects only
56+
`ttl <= 0` — which is .NET's own bound — so `0` never reaches a socket, while `256` passes the
57+
type and `IP_TTL` refuses it with `EINVAL`.
58+
59+
## 4. The correlation is defensive on this platform, and the mutation pass is what established it
60+
61+
Four mutations, two caught:
62+
63+
| Mutation | Result |
64+
|---|---|
65+
| `setsockopt` failures discarded again | caught |
66+
| the matcher compares the **identifier** instead of the sequence | caught — **8 tests**, every live one |
67+
| **the correlation is computed and ignored** | **NOT caught** |
68+
| **the deadline restarts on every foreign datagram** | **NOT caught** |
69+
70+
**The third is the useful one, and it exposed a vacuous test of mine.** A first case claimed to
71+
prove the skip by putting an unrelated echo on the wire. It proves nothing: a Linux ping socket is
72+
demultiplexed by the identifier the **kernel** assigned, so a reply belonging to another socket is
73+
never queued on ours. A foreign datagram cannot arrive here at all.
74+
75+
So the check is **defensive rather than load-bearing on this platform**. It is kept, and the
76+
reason is specific: **#1962** would add a raw-socket fallback, and a raw ICMP socket receives
77+
*every* ICMP datagram on the host — at which point this becomes essential and its absence would be
78+
a live defect instead of a latent one. The test was rewritten to assert what it actually
79+
establishes, and both the test and `Ping.cpp` say so.
80+
81+
**The fourth cannot be caught deterministically.** Observing a restarted timeout needs a
82+
*sustained* flood of foreign datagrams plus a wall-clock assertion — and under the mutation the
83+
call would not fail but **hang**, so the test would be both flaky and useless, the shape #2352 and
84+
#2105 were repaired for. The original deadline is kept because a busy or hostile socket must not
85+
extend this call without bound, which is the same class of defect #2032 removed from
86+
`WaitForExit`.
87+
88+
## 5. Downstream, measured
89+
90+
`cna` and `mobile-eggbert` reference `System::Net::NetworkInformation::Ping` in **zero** code
91+
sites. Neither was modified.

modules/net-network-information/src/System/Net/NetworkInformation/Ping.cpp

Lines changed: 116 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -180,18 +180,87 @@ namespace {
180180
return OwnedDescriptor(fd);
181181
}
182182

183+
/**
184+
* @brief Applies a socket option, reporting a kernel refusal instead of discarding it.
185+
*
186+
* Ticket #2194. Every `setsockopt` here discarded its return value, so an option the kernel
187+
* rejected was silently not applied -- a caller who set `Ttl = 1` expecting a
188+
* `TtlExpired` reply got a normal one and no indication that the option had been dropped.
189+
*/
190+
void setOptionOrThrow(int fd, int level, int name, const void* value, socklen_t size) {
191+
if (::setsockopt(fd, level, name, value, size) != 0) {
192+
throw NetworkInformationException(errno);
193+
}
194+
}
195+
196+
/**
197+
* @brief Whether @p data is a reply that correlates with the request we just sent.
198+
*
199+
* HONEST NOTE: a mutation that RESTARTS the timeout on every foreign datagram (rather than
200+
* keeping the original deadline, as the call site does) is NOT caught. Observing it needs a
201+
* SUSTAINED flood of foreign datagrams plus a wall-clock assertion, and under the mutation
202+
* the call would not fail but HANG -- so the test would be both flaky and useless, the
203+
* shape #2352 and #2105 were repaired for. The original deadline is kept because a busy or
204+
* hostile socket must not be able to extend this call without bound, which is the same
205+
* class of defect #2032 removed from WaitForExit.
206+
*
207+
* Ticket #2194. The correlation is the SEQUENCE NUMBER -- see the long note at the call site
208+
* for why the identifier cannot be used on a ping socket and why the source address must not
209+
* be. An ICMP ERROR reply quotes the original request after its own header, so the sequence
210+
* is read from the quoted request rather than from the error's own header, which carries
211+
* none: that is the same "original IP+ICMP request is in the payload" rule .NET follows
212+
* (`Ping.RawSocket.cs:166-190`).
213+
*/
214+
bool replyMatchesRequest(const uint8_t* data, size_t size, bool isIPv6, uint16_t sequence) {
215+
if (isIPv6) {
216+
if (size < sizeof(icmp6_hdr)) return false;
217+
icmp6_hdr hdr{};
218+
std::memcpy(&hdr, data, sizeof(hdr));
219+
if (hdr.icmp6_type == ICMP6_ECHO_REPLY) return ntohs(hdr.icmp6_seq) == sequence;
220+
// An error quotes the IPv6 header (40 bytes) then the original ICMPv6 header.
221+
const size_t quoted = sizeof(icmp6_hdr) + 40;
222+
if (size < quoted + sizeof(icmp6_hdr)) return false;
223+
icmp6_hdr original{};
224+
std::memcpy(&original, data + quoted, sizeof(original));
225+
return ntohs(original.icmp6_seq) == sequence;
226+
}
227+
if (size < sizeof(IcmpV4Header)) return false;
228+
IcmpV4Header hdr{};
229+
std::memcpy(&hdr, data, sizeof(hdr));
230+
#if defined(SHARP_RUNTIME_PING_LINUX_ICMP)
231+
const uint8_t type = hdr.type;
232+
if (type == ICMP_ECHOREPLY) return ntohs(hdr.un.echo.sequence) == sequence;
233+
#else
234+
const uint8_t type = hdr.icmp_type;
235+
if (type == ICMP_ECHOREPLY) return ntohs(hdr.icmp_seq) == sequence;
236+
#endif
237+
// An error quotes the original IP header, whose length is in its low nibble, then the
238+
// original ICMP header.
239+
if (size < sizeof(IcmpV4Header) + 1) return false;
240+
const size_t quotedIpLength = 4u * static_cast<size_t>(data[sizeof(IcmpV4Header)] & 0x0F);
241+
const size_t quoted = sizeof(IcmpV4Header) + quotedIpLength;
242+
if (quotedIpLength < 20 || size < quoted + sizeof(IcmpV4Header)) return false;
243+
IcmpV4Header original{};
244+
std::memcpy(&original, data + quoted, sizeof(original));
245+
#if defined(SHARP_RUNTIME_PING_LINUX_ICMP)
246+
return ntohs(original.un.echo.sequence) == sequence;
247+
#else
248+
return ntohs(original.icmp_seq) == sequence;
249+
#endif
250+
}
251+
183252
void applyOptions(int fd, bool isIPv6, const PingOptions* options) {
184253
if (options == nullptr) {
185254
return;
186255
}
187256
int ttl = static_cast<int>(options->getTtlProperty());
188257
if (isIPv6) {
189-
::setsockopt(fd, IPPROTO_IPV6, IPV6_UNICAST_HOPS, &ttl, sizeof(ttl));
258+
setOptionOrThrow(fd, IPPROTO_IPV6, IPV6_UNICAST_HOPS, &ttl, sizeof(ttl));
190259
} else {
191-
::setsockopt(fd, IPPROTO_IP, IP_TTL, &ttl, sizeof(ttl));
260+
setOptionOrThrow(fd, IPPROTO_IP, IP_TTL, &ttl, sizeof(ttl));
192261
#if defined(IP_MTU_DISCOVER)
193262
int mode = options->getDontFragmentProperty() ? IP_PMTUDISC_DO : IP_PMTUDISC_WANT;
194-
::setsockopt(fd, IPPROTO_IP, IP_MTU_DISCOVER, &mode, sizeof(mode));
263+
setOptionOrThrow(fd, IPPROTO_IP, IP_MTU_DISCOVER, &mode, sizeof(mode));
195264
#endif
196265
}
197266
}
@@ -210,7 +279,7 @@ PingReply Ping::sendPingCore(const System::Net::IPAddress& address, const std::v
210279
timeval tv{};
211280
tv.tv_sec = timeout / 1000;
212281
tv.tv_usec = (timeout % 1000) * 1000;
213-
::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
282+
setOptionOrThrow(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
214283

215284
static std::atomic<uint16_t> sequenceCounter{0};
216285
uint16_t identifier = static_cast<uint16_t>(::getpid() & 0xFFFF);
@@ -289,8 +358,50 @@ PingReply Ping::sendPingCore(const System::Net::IPAddress& address, const std::v
289358
throw NetworkInformationException(errno);
290359
}
291360

361+
// #2194: read until a datagram that CORRELATES WITH THIS REQUEST arrives, or the deadline
362+
// passes. The old code took the first datagram on the socket unconditionally, so a reply to
363+
// somebody else's outstanding request -- or a stale one of our own -- was reported as the
364+
// answer to this one, with its status and its round-trip time.
365+
//
366+
// THE TICKET'S ACCEPTANCE CRITERION IS WRONG ON TWO OF ITS THREE FIELDS, and both are
367+
// measured rather than argued:
368+
//
369+
// * IDENTIFIER -- cannot be matched here. This runtime opens a SOCK_DGRAM/IPPROTO_ICMP
370+
// "ping socket", and the Linux kernel REWRITES the ICMP identifier: probed directly, a
371+
// request written with id 0x1234 came back as 0x94d4 (the kernel uses the socket's own
372+
// port). .NET checks the identifier (`Ping.RawSocket.cs:230`) because its raw-socket path
373+
// writes an id the kernel leaves alone; on a ping socket that check would reject every
374+
// reply.
375+
// * SOURCE ADDRESS -- deliberately NOT matched, and .NET does not match it either: it
376+
// reports `socketConfig.EndPoint.Address`, the address it SENT to (`:245`). Requiring the
377+
// source to equal the destination would reject the legitimate error replies that come
378+
// from an intermediate router -- TimeExceeded above all, which is exactly what a caller
379+
// setting a low Ttl is asking for.
380+
// * SEQUENCE -- matched. It survives the ping socket unchanged (probed: 0x5678 out, 0x5678
381+
// back), it is ours, and it is the field that distinguishes this request from another.
292382
std::vector<uint8_t> recvBuf(65535);
293-
ssize_t received = ::recv(fd, recvBuf.data(), recvBuf.size(), 0);
383+
const auto deadline = start + std::chrono::milliseconds(timeout);
384+
ssize_t received = -1;
385+
for (;;) {
386+
received = ::recv(fd, recvBuf.data(), recvBuf.size(), 0);
387+
if (received < 0) break;
388+
if (replyMatchesRequest(recvBuf.data(), static_cast<size_t>(received), isIPv6, sequence))
389+
break;
390+
// Not ours. Keep the ORIGINAL deadline rather than restarting the timeout, so a stream
391+
// of foreign datagrams cannot extend this call without bound.
392+
const auto now = std::chrono::steady_clock::now();
393+
if (now >= deadline) {
394+
received = -1;
395+
errno = EAGAIN;
396+
break;
397+
}
398+
const auto remaining =
399+
std::chrono::duration_cast<std::chrono::microseconds>(deadline - now).count();
400+
timeval rest{};
401+
rest.tv_sec = static_cast<time_t>(remaining / 1000000);
402+
rest.tv_usec = static_cast<suseconds_t>(remaining % 1000000);
403+
setOptionOrThrow(fd, SOL_SOCKET, SO_RCVTIMEO, &rest, sizeof(rest));
404+
}
294405
auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - start).count();
295406
// The descriptor stays owned until this function returns (ticket #2193). The `::close(fd)`
296407
// that used to sit here also ran BEFORE the `errno` reads below, and `close()` is allowed to

0 commit comments

Comments
 (0)