diff --git a/src/network/Network.zig b/src/network/Network.zig index 13243d08..1e51f042 100644 --- a/src/network/Network.zig +++ b/src/network/Network.zig @@ -722,8 +722,15 @@ pub fn run(self: *Network) void { break :blk min_timeout; } + // curl_multi_timeout reports -1 when curl has no timeout + // preference (idle) and 0 when it wants to be serviced + // immediately. Treat both as "no curl-imposed deadline" and + // fall back to min_timeout — otherwise @min(min_timeout, -1) + // would be -1, i.e. poll() blocks forever, starving onTick + // (telemetry's periodic flush) and removing the safety net + // that bounds any missed wakeup to min_timeout. const curl_timeout = self.getCurlTimeout(); - if (curl_timeout == 0) { + if (curl_timeout <= 0) { break :blk min_timeout; } @@ -860,7 +867,16 @@ fn acceptConnections(self: *Network) void { } fn preparePollFds(self: *Network, multi: *libcurl.CurlM) void { - const curl_fds = self.pollfds[PSEUDO_POLLFDS..]; + // Only the curl slice — NOT through to the end of pollfds. The CDP + // socket fds live in [cdp_start..] and are owned by + // prepareCdpPollFds, which only rebuilds them when cdp_dirty is set + // (a steady-state optimization). Slicing to the end here would + // @memset those fds to -1 every iteration once a multi exists (which + // happens as soon as telemetry sends its first request), silently + // dropping every live CDP socket from the poll set — Network then + // never reads another CDP message (#2508) nor observes peer + // EOF/shutdown (#2507). + const curl_fds = self.pollfds[PSEUDO_POLLFDS..self.cdp_start]; @memset(curl_fds, .{ .fd = -1, .events = 0, .revents = 0 }); var fd_count: c_uint = 0; @@ -1053,3 +1069,40 @@ fn loadCerts(allocator: Allocator) !libcurl.CurlBlob { .flags = 0, }; } + +const testing = @import("../testing.zig"); + +test "Network: preparePollFds leaves the CDP fd region untouched" { + // Regression for #2507 / #2508. Once a multi exists (telemetry creates + // one in optimized builds), preparePollFds runs every loop iteration. + // It rebuilds only the curl slice [PSEUDO_POLLFDS..cdp_start]; the CDP + // region [cdp_start..] is owned by prepareCdpPollFds, which keeps its + // entries across iterations and only rebuilds when cdp_dirty is set. + // A slice that ran to the end of pollfds @memset those CDP sockets to + // -1, silently dropping every live CDP connection from the poll set — + // so Network stopped reading CDP messages (#2508) and never observed + // peer EOF/shutdown (#2507). curl global is initialized by the test + // harness (App.init -> Network.init). + const multi = libcurl.curl_multi_init() orelse return error.FailedToInitMulti; + defer libcurl.curl_multi_cleanup(multi) catch {}; + + const curl_slots = 4; + const cdp_slots = 3; + var pollfds: [PSEUDO_POLLFDS + curl_slots + cdp_slots]posix.pollfd = undefined; + @memset(&pollfds, .{ .fd = -1, .events = 0, .revents = 0 }); + + // preparePollFds only reads self.pollfds and self.cdp_start. + var nw: Network = undefined; + nw.pollfds = &pollfds; + nw.cdp_start = PSEUDO_POLLFDS + curl_slots; + + // Two live CDP sockets parked in the CDP region, mimicking the steady + // state between cdp_dirty rebuilds. + pollfds[nw.cdp_start] = .{ .fd = 4242, .events = posix.POLL.IN, .revents = 0 }; + pollfds[nw.cdp_start + 1] = .{ .fd = 4243, .events = posix.POLL.IN, .revents = 0 }; + + nw.preparePollFds(multi); + + try testing.expectEqual(@as(posix.fd_t, 4242), pollfds[nw.cdp_start].fd); + try testing.expectEqual(@as(posix.fd_t, 4243), pollfds[nw.cdp_start + 1].fd); +}