Files
spine/tests/unit/test_ping_timing.c
Thomas Vincent 9582b71704 fix(runtime): [develop] retain reviewed probe and compiler stages after result normalization (#660)
The runtime/probe changes from #642 and GNU C17/build changes from #643
were squash-merged into #641's source branch, which made the
result-normalization review include later stages. This follow-up
preserves those approved changes while restoring #641's focused diff
against develop. No force-push or approved-code deletion is required.

Merge after #641 and before #644. #644 will target this branch. This
combines the already reviewed steps 5 and 6; it introduces no new
worker-stack or module-extraction changes.

The UDP follow-up also corrects a validated finding: EHOSTUNREACH on a
connected socket is ambiguous and can represent an unreachable host or
failed neighbour lookup. It now reports down; ECONNREFUSED/ECONNRESET
and actual peer datagrams retain their existing alive behavior. The
production-linked test rejects the original EHOSTUNREACH behavior.

Validation: Ubuntu 24.04/aarch64, clean GCC build and make check: 17
passed, one existing setuid prerequisite skip, zero failures. The full
tracked source tree is byte-identical to the independently validated
#641 functional-fix snapshot. The UDP and poll-host regressions fail
against the original code and pass after the fixes. The isolated step-4
tree independently passes 15 tests with one existing skip. No live
endpoints, host installation, privilege changes, or test suppression.

Repository squash merges still require restacking/retargeting the next
child after each merge; clean ordinary merge simulations are
insufficient by themselves.

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
2026-10-19 14:17:35 -04:00

536 lines
17 KiB
C

/* Reachability verdicts and timing in ping.c.
*
* Each case drives the shipped probe through controlled socket calls, so a
* lost packet, a late reply or an ICMP error arrives exactly when the case
* needs it. Nothing here needs root, raw sockets or a route off the host.
*/
#include <stdarg.h>
#include <stddef.h>
#include <setjmp.h>
#include <cmocka.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <pthread.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include "common.h"
#include "spine.h"
#include "ping.h"
extern int *debug_devices;
static int pt_debug_table[100];
static int controlled_pair[2] = {-1, -1};
static int use_controlled_socket;
static int icmp6_mode;
static int sends;
static int drop_sends;
static int fake_select_timeouts;
static struct timeval first_select_timeout;
static int select_calls;
static unsigned char last_request[64];
static size_t last_request_length;
static int recv_errno;
static int recv_errno_limit;
static int recv_calls;
static int test_socket(int domain, int type, int protocol);
static ssize_t test_sendto(int fd, const void *buffer, size_t length, int flags,
const struct sockaddr *address, socklen_t address_len);
static int test_select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
static ssize_t test_recvfrom(int fd, void *buffer, size_t length, int flags,
struct sockaddr *address, socklen_t *address_len);
static ssize_t test_recv(int fd, void *buffer, size_t length, int flags);
#define socket test_socket
#define sendto test_sendto
#define select test_select
#define recvfrom test_recvfrom
#define recv test_recv
#include "../../ping.c"
#undef socket
#undef sendto
#undef select
#undef recvfrom
#undef recv
static int test_socket(int domain, int type, int protocol) {
if (use_controlled_socket && type != SOCK_STREAM && protocol != IPPROTO_UDP) {
return controlled_pair[0];
}
return socket(domain, type, protocol);
}
static ssize_t test_sendto(int fd, const void *buffer, size_t length, int flags,
const struct sockaddr *address, socklen_t address_len) {
if (!use_controlled_socket || fd != controlled_pair[0]) {
return sendto(fd, buffer, length, flags, address, address_len);
}
if (length > sizeof(last_request)) {
errno = EMSGSIZE;
return -1;
}
memcpy(last_request, buffer, length);
last_request_length = length;
sends++;
return (ssize_t) length;
}
/* The first drop_sends probes are lost: their wait runs the real select()
* on a quiet socket, so the clock moves exactly as it would on the wire.
* Later probes are answered at once. */
static int test_select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout) {
if (!use_controlled_socket) {
return select(nfds, readfds, writefds, exceptfds, timeout);
}
if (select_calls++ == 0 && timeout != NULL) {
first_select_timeout = *timeout;
}
if (fake_select_timeouts) {
return 0;
}
if (sends <= drop_sends) {
return select(nfds, readfds, writefds, exceptfds, timeout);
}
return 1;
}
static ssize_t test_recvfrom(int fd, void *buffer, size_t length, int flags,
struct sockaddr *address, socklen_t *address_len) {
unsigned char *reply = buffer;
if (!use_controlled_socket || fd != controlled_pair[0]) {
return recvfrom(fd, buffer, length, flags, address, address_len);
}
if (sends <= drop_sends || fake_select_timeouts) {
errno = EAGAIN;
return -1;
}
/* Echo the probe back as the reply a datagram ICMP socket delivers. */
assert_true(length >= last_request_length);
memcpy(reply, last_request, last_request_length);
#ifdef SPINE_HAVE_ICMPV6
if (icmp6_mode) {
struct sockaddr_in6 *source = (struct sockaddr_in6 *) address;
reply[0] = ICMP6_ECHO_REPLY;
assert_true(*address_len >= sizeof(*source));
memset(source, 0, sizeof(*source));
source->sin6_family = AF_INET6;
source->sin6_addr = in6addr_loopback;
*address_len = sizeof(*source);
} else
#endif
{
struct sockaddr_in *source = (struct sockaddr_in *) address;
reply[0] = ICMP_ECHOREPLY;
assert_true(*address_len >= sizeof(*source));
memset(source, 0, sizeof(*source));
source->sin_family = AF_INET;
source->sin_addr.s_addr = htonl(INADDR_LOOPBACK);
*address_len = sizeof(*source);
}
return (ssize_t) last_request_length;
}
/* Real recv(), then the errno a connected UDP socket would report if the
* matching ICMP error had arrived instead of the datagram. */
static ssize_t test_recv(int fd, void *buffer, size_t length, int flags) {
ssize_t received = recv(fd, buffer, length, flags);
recv_calls++;
if (received >= 0 && recv_errno != 0 &&
(recv_errno_limit == 0 || recv_calls <= recv_errno_limit)) {
errno = recv_errno;
return -1;
}
return received;
}
static void make_host(host_t *host, const char *address, int method) {
memset(host, 0, sizeof(*host));
host->id = 1;
snprintf(host->hostname, sizeof(host->hostname), "%s", address);
host->availability.timeout = 300;
host->availability.retries = 1;
host->availability.method = AVAIL_PING;
host->availability.ping_method = method;
}
static int timing_setup(void **state) {
(void) state;
config_defaults();
init_mutexes();
memset(pt_debug_table, 0, sizeof(pt_debug_table));
debug_devices = pt_debug_table;
set.availability.icmp_uses_caps = TRUE;
use_controlled_socket = 0;
icmp6_mode = 0;
sends = 0;
drop_sends = 0;
fake_select_timeouts = 0;
select_calls = 0;
memset(&first_select_timeout, 0, sizeof(first_select_timeout));
last_request_length = 0;
recv_errno = 0;
recv_errno_limit = 0;
recv_calls = 0;
return 0;
}
static int timing_teardown(void **state) {
(void) state;
if (controlled_pair[0] != -1) close(controlled_pair[0]);
if (controlled_pair[1] != -1) close(controlled_pair[1]);
controlled_pair[0] = -1;
controlled_pair[1] = -1;
return 0;
}
static void use_controlled_icmp_socket(void) {
assert_int_equal(socketpair(AF_UNIX, SOCK_DGRAM, 0, controlled_pair), 0);
use_controlled_socket = 1;
}
/* --- G4: one lost probe must not use up the retry budget ------------------ */
static void test_icmp_retry_after_one_lost_probe_is_alive(void **state) {
host_t host;
ping_t ping;
(void) state;
use_controlled_icmp_socket();
drop_sends = 1;
make_host(&host, "127.0.0.1", PING_ICMP);
memset(&ping, 0, sizeof(ping));
assert_int_equal(ping_icmp(&host, &ping), HOST_UP);
assert_int_equal(sends, 2);
assert_string_equal(ping.ping_response, "ICMP: Device is Alive");
/* the reported time is the round trip of the answered probe, not the
* time since the first one was lost */
assert_true(atof(ping.ping_status) < host.availability.timeout);
}
#ifdef SPINE_HAVE_ICMPV6
static void test_icmpv6_retry_after_one_lost_probe_is_alive(void **state) {
host_t host;
ping_t ping;
(void) state;
use_controlled_icmp_socket();
icmp6_mode = 1;
drop_sends = 1;
make_host(&host, "::1", PING_ICMP);
memset(&ping, 0, sizeof(ping));
assert_int_equal(ping_icmp_ipv6(&host, &ping), HOST_UP);
assert_int_equal(sends, 2);
assert_string_equal(ping.ping_response, "ICMPv6: Device is Alive");
assert_true(atof(ping.ping_status) < host.availability.timeout);
}
#endif
/* --- G8: a 1500 ms timeout waits 1500 ms, not 2500 ------------------------ */
static long timeval_usec(const struct timeval *tv) {
return (long) tv->tv_sec * 1000000L + (long) tv->tv_usec;
}
static void test_wait_left_converts_without_rounding_up(void **state) {
static const double offsets[] = {0.25, 0.5, 0.75, 1.4, 1.5, 1.6, 9.999};
struct timeval tv;
size_t i;
(void) state;
for (i = 0; i < sizeof(offsets) / sizeof(offsets[0]); i++) {
long expected = (long) (offsets[i] * 1000000.0);
ping_wait_left(spine_monotonic_time() + offsets[i], &tv);
assert_true(tv.tv_usec >= 0 && tv.tv_usec < 1000000);
assert_true(timeval_usec(&tv) <= expected);
assert_true(timeval_usec(&tv) > expected - 50000L);
}
/* a deadline already behind us polls instead of waiting */
ping_wait_left(spine_monotonic_time() - 1.0, &tv);
assert_int_equal(timeval_usec(&tv), 0);
}
static void test_icmp_wait_matches_the_device_timeout(void **state) {
host_t host;
ping_t ping;
long waited;
(void) state;
use_controlled_icmp_socket();
fake_select_timeouts = 1;
make_host(&host, "127.0.0.1", PING_ICMP);
host.availability.timeout = 1500;
host.availability.retries = 0;
memset(&ping, 0, sizeof(ping));
assert_int_equal(ping_icmp(&host, &ping), HOST_DOWN);
waited = timeval_usec(&first_select_timeout);
assert_true(waited <= 1500000L);
assert_true(waited > 1400000L);
}
#ifdef SPINE_HAVE_ICMPV6
static void test_icmpv6_wait_matches_the_device_timeout(void **state) {
host_t host;
ping_t ping;
long waited;
(void) state;
use_controlled_icmp_socket();
icmp6_mode = 1;
fake_select_timeouts = 1;
make_host(&host, "::1", PING_ICMP);
host.availability.timeout = 1500;
host.availability.retries = 0;
memset(&ping, 0, sizeof(ping));
assert_int_equal(ping_icmp_ipv6(&host, &ping), HOST_DOWN);
waited = timeval_usec(&first_select_timeout);
assert_true(waited <= 1500000L);
assert_true(waited > 1400000L);
}
#endif
/* --- G6: what a UDP probe may take as proof of life ----------------------- */
typedef struct {
int fd;
int reply;
int probes;
} udp_peer_t;
/* Answers one probe, which is what makes the client socket readable. The
* injected errno then decides what that readability meant. */
static void *udp_peer(void *argument) {
udp_peer_t *peer = argument;
struct sockaddr_storage client;
socklen_t length = sizeof(client);
char buffer[64];
ssize_t received;
int probes;
/* shutdown() does not wake a parked recvfrom() on every platform, so the
* peer waits for exactly the probes the client will send */
for (probes = 0; probes < peer->probes; probes++) {
length = sizeof(client);
received = recvfrom(peer->fd, buffer, sizeof(buffer), 0, (struct sockaddr *) &client, &length);
if (received <= 0) {
break;
}
if (peer->reply) {
sendto(peer->fd, "pong", 4, 0, (struct sockaddr *) &client, length);
}
}
return NULL;
}
static int run_udp_probe_retries(int injected_errno, int reply, int retries, ping_t *ping) {
struct sockaddr_in address;
socklen_t length = sizeof(address);
udp_peer_t peer;
pthread_t thread;
host_t host;
int rc;
peer.fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
assert_true(peer.fd >= 0);
peer.reply = reply;
peer.probes = retries + 1;
memset(&address, 0, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
assert_int_equal(bind(peer.fd, (struct sockaddr *) &address, sizeof(address)), 0);
assert_int_equal(getsockname(peer.fd, (struct sockaddr *) &address, &length), 0);
assert_int_equal(pthread_create(&thread, NULL, udp_peer, &peer), 0);
make_host(&host, "127.0.0.1", PING_UDP);
host.availability.port = ntohs(address.sin_port);
host.availability.retries = retries;
host.availability.timeout = 500;
recv_errno = injected_errno;
memset(ping, 0, sizeof(*ping));
rc = ping_udp(&host, ping);
/* a peer still parked in recvfrom() would hang the join */
shutdown(peer.fd, SHUT_RDWR);
assert_int_equal(pthread_join(thread, NULL), 0);
close(peer.fd);
return rc;
}
static int run_udp_probe(int injected_errno, int reply, ping_t *ping) {
return run_udp_probe_retries(injected_errno, reply, 0, ping);
}
/* EHOSTUNREACH includes ordinary unreachable hosts and failed neighbour
* resolution; it is not evidence that the device responded. */
static void test_udp_host_unreachable_is_down(void **state) {
ping_t ping;
(void) state;
assert_int_equal(run_udp_probe(EHOSTUNREACH, 1, &ping), HOST_DOWN);
assert_true(recv_calls >= 1);
assert_string_equal(ping.ping_response, "UDP: Ping timed out");
}
/* One unreachable report, for instance while a neighbour entry resolves, uses
* up an attempt but must not stop the retries that follow. */
static void test_udp_unreachable_then_reply_is_alive(void **state) {
ping_t ping;
(void) state;
recv_errno_limit = 1;
assert_int_equal(run_udp_probe_retries(EHOSTUNREACH, 1, 1, &ping), HOST_UP);
assert_string_equal(ping.ping_response, "UDP: Device is Alive");
}
static void test_udp_network_unreachable_is_down(void **state) {
ping_t ping;
(void) state;
assert_int_equal(run_udp_probe(ENETUNREACH, 1, &ping), HOST_DOWN);
assert_true(recv_calls >= 1);
}
#ifdef EHOSTDOWN
static void test_udp_host_down_is_down(void **state) {
ping_t ping;
(void) state;
assert_int_equal(run_udp_probe(EHOSTDOWN, 1, &ping), HOST_DOWN);
assert_true(recv_calls >= 1);
}
#endif
static void test_udp_port_unreachable_is_alive(void **state) {
ping_t ping;
(void) state;
assert_int_equal(run_udp_probe(ECONNREFUSED, 1, &ping), HOST_UP);
assert_string_equal(ping.ping_response, "UDP: Device is Alive");
}
static void test_udp_data_reply_is_alive(void **state) {
ping_t ping;
(void) state;
assert_int_equal(run_udp_probe(0, 1, &ping), HOST_UP);
assert_string_equal(ping.ping_response, "UDP: Device is Alive");
}
/* --- G9: transport-qualified and bracketed IPv6 device names -------------- */
static void test_namebyhost_table(void **state) {
static const struct {
const char *input;
const char *hostname;
int method;
int port;
} cases[] = {
{"router.example", "router.example", 0, 0},
{"router.example:161", "router.example", 0, 161},
{"TCP:router.example:443", "router.example", 1, 443},
{"udp:router.example:53", "router.example", 2, 53},
{"tcp6:router.example:22", "router.example", 3, 22},
{"192.0.2.7", "192.0.2.7", 0, 0},
{"2001:db8::1", "2001:db8::1", 0, 0},
{"fe80::1%eth0", "fe80::1%eth0", 0, 0},
{"[2001:db8::1]", "[2001:db8::1]", 0, 0},
{"[::1]:161", "[::1]:161", 0, 161},
{"udp6:[2001:db8::1]:161", "udp6:[2001:db8::1]:161", 4, 161},
{"tcp6:[::1]:22", "tcp6:[::1]:22", 3, 22},
{"UDP6:[::1]", "UDP6:[::1]", 4, 0},
{"udp6:[::1]:notaport", "udp6:[::1]:notaport", 4, 0},
};
size_t i;
(void) state;
for (i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
name_t *name = get_namebyhost(cases[i].input, NULL);
assert_non_null(name);
if (strcmp(name->hostname, cases[i].hostname) != 0 ||
name->method != cases[i].method || name->port != cases[i].port) {
fail_msg("get_namebyhost(\"%s\") gave {\"%s\", %d, %d}, expected {\"%s\", %d, %d}",
cases[i].input, name->hostname, name->method, name->port,
cases[i].hostname, cases[i].method, cases[i].port);
}
free(name);
}
}
#ifdef SPINE_HAVE_ICMPV6
static void test_icmpv6_resolves_transport_qualified_names(void **state) {
static const char *const names[] = {
"udp6:[::1]:161", "tcp6:[::1]:22", "[::1]:161", "[::1]", "UDP6:[::1]", "::1"
};
struct sockaddr_in6 address;
size_t i;
(void) state;
for (i = 0; i < sizeof(names) / sizeof(names[0]); i++) {
memset(&address, 0, sizeof(address));
if (!init_sockaddr6(&address, names[i])) {
fail_msg("init_sockaddr6(\"%s\") failed", names[i]);
}
assert_memory_equal(&address.sin6_addr, &in6addr_loopback, sizeof(struct in6_addr));
}
}
static void test_icmpv6_pings_a_transport_qualified_name(void **state) {
host_t host;
ping_t ping;
(void) state;
use_controlled_icmp_socket();
icmp6_mode = 1;
make_host(&host, "udp6:[::1]:161", PING_ICMP);
memset(&ping, 0, sizeof(ping));
assert_int_equal(ping_icmp_ipv6(&host, &ping), HOST_UP);
}
#endif
int main(void) {
const struct CMUnitTest tests[] = {
cmocka_unit_test_setup_teardown(test_icmp_retry_after_one_lost_probe_is_alive, timing_setup, timing_teardown),
cmocka_unit_test_setup_teardown(test_wait_left_converts_without_rounding_up, timing_setup, timing_teardown),
cmocka_unit_test_setup_teardown(test_icmp_wait_matches_the_device_timeout, timing_setup, timing_teardown),
#ifdef SPINE_HAVE_ICMPV6
cmocka_unit_test_setup_teardown(test_icmpv6_retry_after_one_lost_probe_is_alive, timing_setup, timing_teardown),
cmocka_unit_test_setup_teardown(test_icmpv6_wait_matches_the_device_timeout, timing_setup, timing_teardown),
#endif
cmocka_unit_test_setup_teardown(test_udp_host_unreachable_is_down, timing_setup, timing_teardown),
cmocka_unit_test_setup_teardown(test_udp_network_unreachable_is_down, timing_setup, timing_teardown),
cmocka_unit_test_setup_teardown(test_udp_unreachable_then_reply_is_alive, timing_setup, timing_teardown),
#ifdef EHOSTDOWN
cmocka_unit_test_setup_teardown(test_udp_host_down_is_down, timing_setup, timing_teardown),
#endif
cmocka_unit_test_setup_teardown(test_udp_port_unreachable_is_alive, timing_setup, timing_teardown),
cmocka_unit_test_setup_teardown(test_udp_data_reply_is_alive, timing_setup, timing_teardown),
cmocka_unit_test_setup_teardown(test_namebyhost_table, timing_setup, timing_teardown),
#ifdef SPINE_HAVE_ICMPV6
cmocka_unit_test_setup_teardown(test_icmpv6_resolves_transport_qualified_names, timing_setup, timing_teardown),
cmocka_unit_test_setup_teardown(test_icmpv6_pings_a_transport_qualified_name, timing_setup, timing_teardown),
#endif
};
return cmocka_run_group_tests(tests, NULL, NULL);
}