diff --git a/.release-notes/5347.md b/.release-notes/5347.md new file mode 100644 index 0000000000..774fc0593a --- /dev/null +++ b/.release-notes/5347.md @@ -0,0 +1,6 @@ +## Fix delivery of zero-byte UDP datagrams + +UDP sockets now deliver zero-byte datagrams to `UDPNotify.received` with an +empty payload instead of treating them as an error. This preserves valid UDP +uses such as heartbeat, keepalive, and presence datagrams without changing TCP +zero-byte read handling. diff --git a/packages/net/_test.pony b/packages/net/_test.pony index 1d7b2d6fbe..82371a07ba 100644 --- a/packages/net/_test.pony +++ b/packages/net/_test.pony @@ -27,6 +27,7 @@ actor \nodoc\ Main is TestList test(_TestTCPProxy) test(_TestTCPUnmute) test(_TestTCPWritev) + test(_TestUDPZeroByteDatagram) // Tests below exclude windows and are listed alphabetically ifdef not windows then @@ -155,6 +156,80 @@ class \nodoc\ iso _TestBroadcast is UnitTest exclude this test by passing the --exclude="net/Broadcast" option. """) +class \nodoc\ _TestUDPZeroByteDatagramReceiver is UDPNotify + let _h: TestHelper + + new create(h: TestHelper) => + _h = h + + fun ref not_listening(sock: UDPSocket ref) => + _h.fail_action("receiver listen") + + fun ref listening(sock: UDPSocket ref) => + _h.complete_action("receiver listen") + + let ip = sock.local_address() + let h = _h + if ip.ip4() then + _h.dispose_when_done( + UDPSocket.ip4(UDPAuth(h.env.root), + recover _TestUDPZeroByteDatagramSender(h, ip) end)) + else + _h.dispose_when_done( + UDPSocket.ip6(UDPAuth(h.env.root), + recover _TestUDPZeroByteDatagramSender(h, ip) end)) + end + + fun ref received( + sock: UDPSocket ref, + data: Array[U8] iso, + from: NetAddress) + => + _h.complete_action("receiver receive") + _h.assert_eq[USize](0, data.size()) + _h.complete(true) + +class \nodoc\ _TestUDPZeroByteDatagramSender is UDPNotify + let _h: TestHelper + let _ip: NetAddress + + new create(h: TestHelper, ip: NetAddress) => + _h = h + _ip = ip + + fun ref not_listening(sock: UDPSocket ref) => + _h.fail_action("sender listen") + + fun ref listening(sock: UDPSocket ref) => + _h.complete_action("sender listen") + sock.write("", _ip) + + fun ref received( + sock: UDPSocket ref, + data: Array[U8] iso, + from: NetAddress) + => + _h.fail("sender received unexpected datagram") + +class \nodoc\ iso _TestUDPZeroByteDatagram is UnitTest + """ + Test receiving an empty UDP datagram. + """ + fun name(): String => "net/UDPZeroByteDatagram" + fun label(): String => "unreliable-appveyor-osx" + fun exclusion_group(): String => "network" + + fun ref apply(h: TestHelper) => + h.expect_action("receiver listen") + h.expect_action("sender listen") + h.expect_action("receiver receive") + + h.dispose_when_done( + UDPSocket(UDPAuth(h.env.root), + recover _TestUDPZeroByteDatagramReceiver(h) end)) + + h.long_test(TimeoutValue()) + class \nodoc\ _TestTCP is TCPListenNotify """ Run a typical TCP test consisting of a single TCPListener that accepts a diff --git a/packages/net/udp_socket.pony b/packages/net/udp_socket.pony index 1ef1e370d5..350cfb21d1 100644 --- a/packages/net/udp_socket.pony +++ b/packages/net/udp_socket.pony @@ -290,9 +290,11 @@ actor UDPSocket is AsioEventNotify fun ref _pending_reads() => """ - Read while data is available, guessing the next packet length as we go. If - we read 4 kb of data, send ourself a resume message and stop reading, to - avoid starving other actors. + Read while data is available, guessing the next packet length as we go. + After roughly 4 KB worth of read work, send ourself a resume message and + stop reading, to avoid starving other actors. Zero-byte datagrams count as + 1 byte against this budget so a stream of empty datagrams cannot keep this + loop running forever. """ ifdef not windows then try @@ -306,7 +308,7 @@ actor UDPSocket is AsioEventNotify @pony_os_recvfrom(_event, data.cpointer(), data.space(), from) ? - if len == 0 then + if len == USize.max_value() then _readable = false return end @@ -314,7 +316,10 @@ actor UDPSocket is AsioEventNotify data.truncate(len) _notify.received(this, consume data, consume from) - sum = sum + len + // .max(1) charges 1 byte against the budget for a zero-byte + // datagram, preventing this loop from starving other actors on a + // stream of empty datagrams. + sum = sum + len.max(1) if sum > (1 << 12) then _read_again() diff --git a/src/libponyrt/lang/socket.c b/src/libponyrt/lang/socket.c index bf6f353c50..31570f3a8b 100644 --- a/src/libponyrt/lang/socket.c +++ b/src/libponyrt/lang/socket.c @@ -1119,10 +1119,8 @@ PONY_API size_t pony_os_recvfrom(asio_event_t* ev, char* buf, size_t len, if(recvd < 0) { if(errno == EWOULDBLOCK || errno == EAGAIN) - return 0; + return (size_t)-1; - pony_error(); - } else if(recvd == 0) { pony_error(); }