Skip to content

netfix-receive-drain-skips-empty-datagrams-and-stops-at-a-cap: an empty datagram is skipped and a drain stops at its cap - #194

Merged
rowan-claude merged 3 commits into
sprint/netcode-audit-fixesfrom
sprint/netfix-receive-drain-skips-empty-datagrams-and-stops-at-a-cap.w1.g1.e20
Oct 14, 2026
Merged

rowan-claude merged 3 commits into
sprint/netcode-audit-fixesfrom
sprint/netfix-receive-drain-skips-empty-datagrams-and-stops-at-a-cap.w1.g1.e20

Conversation

@rowan-claude

Copy link
Copy Markdown
Contributor

Card: netfix-receive-drain-skips-empty-datagrams-and-stops-at-a-cap (stream netcode-audit-fixes, blocker). Work card .w1, generation 1, epoch 20.

The defect

A zero-length UDP datagram is legal, and recvfrom returns 0 for it. netcode read 0 as "the socket is drained", so one empty datagram ended the receive loop for that update, on both the server and the client. The socket loops were also while ( 1 ) with no per-update cap. Anyone who could reach the port could send a few hundred empty datagrams a second (a few KB/s, no authentication needed) and keep the kernel queue growing until it filled, and then every client's packets were dropped.

The fix (netcode.c only, no wire change)

  • A new netcode_classify_receive_result( result, error, would_block ) decides what one recvfrom result means: a positive result is the byte count, 0 means would-block (drained), and -1 means skip this datagram and keep draining. -1 covers a zero-length datagram and any other recvfrom error. netcode_socket_error_would_block holds the per-platform would-block test (EAGAIN or EWOULDBLOCK; WSAEWOULDBLOCK on Windows).
  • Both socket receive loops now do if ( packet_bytes == 0 ) break; if ( packet_bytes < 0 ) continue;. They count every iteration and stop at NETCODE_SERVER_MAX_RECEIVE_PACKETS on the server and NETCODE_CLIENT_MAX_RECEIVE_PACKETS on the client, the same caps the network simulator path already used.
  • receive_packet_override keeps its meaning (0 = nothing more). It may now also return -1 for "skip".

Commits (one per step)

  1. d0c8f7a extracts the classifier with no behaviour change. The full suite stays green.
  2. d8ad227 adds the red tests: test_receive_result_classification, test_receive_drain_stops_at_cap (server and client), and test_receive_drain_skips_empty_datagram (a pin). They use the receive override with a scripted queue: no socket, and time is passed as an argument.
  3. 95b2b91 is the fix.

Red at d8ad227 (Debug, hetzner1)

test_receive_result_classification
check failed: ( netcode_classify_receive_result( 0, 0, netcode_socket_error_would_block( 0 ) ) == -1 ), function test_receive_result_classification, file .../netcode.c, line 10552

The same tree with the classification test unregistered, to show the cap test red:

test_receive_drain_stops_at_cap
check failed: ( test_receive_drain.num_calls == NETCODE_SERVER_MAX_RECEIVE_PACKETS ), function test_receive_drain_stops_at_cap, file .../netcode.c, line 10672

Green at 95b2b91 (hetzner1)

Debug ./build/bin/test, exit 0:

test_receive_drain_skips_empty_datagram
test_receive_result_classification
test_receive_drain_stops_at_cap
test_packet_tagging

*** ALL TESTS PASSED ***

ASan+UBSan (-DNETCODE_SANITIZE=ON) ./build-asan/bin/test, exit 0, 0 sanitizer lines:

test_receive_drain_skips_empty_datagram
test_receive_result_classification
test_receive_drain_stops_at_cap
test_packet_tagging

*** ALL TESTS PASSED ***

Notes for the reader

  • Proposed netcode.h comment (outside this card's PATHS, so it is not in this PR): beside both receive_packet_override fields, // returns bytes received, 0 when there is nothing more to read, or -1 to skip this datagram and keep reading.
  • What the skip pin checks: it pins that a -1 entry does not end the drain (one update reads all three entries and then the drained 0). It cannot observe whether the -1 reaches netcode_server_read_and_process_packet, because that function returns at once for packet_bytes <= 1. The continue before the call is what guarantees it.
  • Not changed: the server still tries the IPv6 socket only when the IPv4 read returned 0 (drained). A sustained IPv4 flood can therefore use up an update's cap before IPv6 is read. That was already true for any IPv4 flood before this change, and it is left for a follow-up card.
  • netcode_sockaddr_to_address failing after a successful read still returns 0. It is asserted unreachable and was not touched.
  • yojimbo's vendored copy of netcode.c is a later card.

Worked by Rowan (Opus lane)

🤖 Generated with Claude Code

rowan-claude and others added 3 commits October 13, 2026 20:16
…ceive result is classified in one function

By: Rowan

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sts for the empty-datagram drain stop and the missing cap

By: Rowan

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ty datagram is skipped and a drain stops at its cap

By: Rowan

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rowan-claude
rowan-claude merged commit 7cd5f51 into sprint/netcode-audit-fixes Oct 14, 2026
18 of 19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant