From 4fc8c47fa84f0670ffb7f309c5ea0f3ab670f6ba Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 16 May 2026 12:44:49 -0700 Subject: [PATCH 1/2] [Fix] UDP recvfrom treats zero-byte datagrams as an error (issue #5289) Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- .release-notes/5347.md | 6 +++ packages/net/_test.pony | 74 ++++++++++++++++++++++++++++++++++++ packages/net/udp_socket.pony | 4 +- src/libponyrt/lang/socket.c | 4 +- 4 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 .release-notes/5347.md 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..54b269df79 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,79 @@ 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 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..44b5b01ca7 100644 --- a/packages/net/udp_socket.pony +++ b/packages/net/udp_socket.pony @@ -306,7 +306,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 +314,7 @@ actor UDPSocket is AsioEventNotify data.truncate(len) _notify.received(this, consume data, consume from) - sum = sum + len + 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(); } From 85473d0dc28da0dea3715ec97d74ab93ff39d4bc Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 17 May 2026 23:15:58 -0700 Subject: [PATCH 2/2] docs: clarify _pending_reads budget, label UDP zero-byte test as flaky Two small follow-ups after a self-review pass with ponylang/llm-skills' pony-code-review skill: - packages/net/udp_socket.pony: expand the _pending_reads docstring to describe the actual budget semantics (4 KB of read work, with empty datagrams charged 1 byte each) and add an inline comment explaining the .max(1) DoS guard. - packages/net/_test.pony: add the "unreliable-appveyor-osx" label on _TestUDPZeroByteDatagram, matching _TestBroadcast precedent for UDP tests that bind to the wildcard address and rely on local_address() delivery. --- packages/net/_test.pony | 1 + packages/net/udp_socket.pony | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/net/_test.pony b/packages/net/_test.pony index 54b269df79..82371a07ba 100644 --- a/packages/net/_test.pony +++ b/packages/net/_test.pony @@ -216,6 +216,7 @@ 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) => diff --git a/packages/net/udp_socket.pony b/packages/net/udp_socket.pony index 44b5b01ca7..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 @@ -314,6 +316,9 @@ actor UDPSocket is AsioEventNotify data.truncate(len) _notify.received(this, consume data, consume from) + // .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