Skip to content

Commit 94726c3

Browse files
gafferongamesclaude
andcommitted
Add netcode_server_create_error, distinguishing bind failures
The server-side mirror of netcode_client_create_error: each failure path in server create records a NETCODE_SERVER_CREATE_ERROR_* code, queryable via netcode_server_create_error() when create returns NULL. Bind failures are reported separately from other socket errors, per address family: a port already in use is the common operational failure for dedicated servers, and callers want to react to it (try the next port) differently than to a socket that could not be created at all. With this, create failures are queryable on both client and server. The running server intentionally has no state machine beyond netcode_server_running: its only states are stopped and started, and everything else is per-client. Adds test_server_create_error covering every reachable code, including real port-in-use bind collisions for both address families. Suite is now 42 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent de310e5 commit 94726c3

3 files changed

Lines changed: 133 additions & 26 deletions

File tree

CLAUDE.md

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ independent implementations (C#, Go, Rust, TypeScript).
1414
- `sodium/` — vendored subset of libsodium, amalgamated into a single `sodium.h` +
1515
`sodium.c` pair (see `sodium/NOTES.md` for how it is generated and validated).
1616
- Build: CMake. `cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build --parallel`,
17-
then `ctest --test-dir build --output-on-failure` runs the suite (41 tests). The
17+
then `ctest --test-dir build --output-on-failure` runs the suite (42 tests). The
1818
`netcode_test` target compiles netcode.c into itself with `NETCODE_ENABLE_TESTS`, so it
1919
links only sodium. `-DNETCODE_SANITIZE=ON` adds ASan+UBSan (sodium gets ASan only);
2020
`-DNETCODE_FUZZ=ON` builds the `fuzz/` harnesses (libFuzzer where available, else a
@@ -31,10 +31,8 @@ independent implementations (C#, Go, Rust, TypeScript).
3131

3232
This is a mature, disciplined, security-conscious C library that does exactly one thing
3333
and does it well. The protocol design is its strongest asset. The main weaknesses are
34-
maintainability of the single-file implementation (internal duplication) and error
35-
reporting that goes quiet at creation time and on the server side — the client's
36-
state machine covers connection errors well, but create returning NULL and the server
37-
API tell the integrator nothing about why something failed.
34+
maintainability of the single-file implementation (internal duplication) and per-packet
35+
allocation churn on the receive path.
3836

3937
### What's genuinely good
4038

@@ -72,7 +70,7 @@ hard-disconnecting client doesn't wedge `recvfrom` (netcode.c:554), `IPV6_V6ONLY
7270
dual-stack IPv4+IPv6 sockets, loopback clients for integrated host-and-play, allocator
7371
override hooks, and full send/receive transport overrides. A built-in network simulator
7472
(latency/jitter/loss/duplication) makes the connection tests deterministic without
75-
touching real sockets. Few networking libraries ship this complete a test story: 41
73+
touching real sockets. Few networking libraries ship this complete a test story: 42
7674
unit + integration tests covering every client error state, reconnect, multi-server
7775
fallback, dual-stack, loopback — plus a soak test and a profiler. All pass today.
7876

@@ -81,6 +79,19 @@ explicit `netcode_client_update(client, time)` — time is injected, not sampled
8179
makes the state machines testable and frame-loop friendly. The header is a clean ~300
8280
lines with zero dependencies beyond stdint.
8381

82+
**Error reporting matches the transport's nature.** Asynchronous connection failures
83+
(denial, timeouts, token problems) surface through the client's negative
84+
`NETCODE_CLIENT_STATE_*` values polled from the game loop — the only channel that can
85+
carry errors that happen seconds after the call that caused them. Create failures are
86+
queryable: `netcode_client_create_error()` / `netcode_server_create_error()` report why
87+
create returned NULL, with server bind failures (port already in use — the common
88+
operational failure) distinguished from other socket errors per address family.
89+
Per-packet socket errors are deliberately ignored: UDP is unreliable, so a send error
90+
is semantically identical to a dropped packet, and a persistently dead socket surfaces
91+
the same way persistent loss does — as a connection timeout through the state machine.
92+
The running server deliberately has no state machine of its own: its only states are
93+
stopped and started (`netcode_server_running`), and everything else is per-client.
94+
8495
### What's not so good
8596

8697
**Internal duplication is the biggest code-quality issue.**
@@ -100,24 +111,6 @@ default behavior is allocation churn proportional to packet rate, and the intern
100111
design (allocate → inspect → free) makes even keep-alives cost a round trip through the
101112
allocator.
102113

103-
**Error reporting goes quiet outside the client state machine.** Client connection
104-
errors are reported well: denial, timeouts, and token problems are asynchronous by
105-
nature, and the negative `NETCODE_CLIENT_STATE_*` values polled from the game loop are
106-
the right mechanism for them — that is by design, not a gap. Per-packet socket errors
107-
being ignored (`sendto` results cast to void, `recvfrom` errors logged and dropped) is
108-
also by design, not a gap: UDP is unreliable, so a send error is semantically identical
109-
to a dropped packet, and the protocol must tolerate drops anyway. A persistently dead
110-
socket surfaces the same way persistent packet loss does — as a connection timeout
111-
through the state machine, which is the correct channel for an unreliable protocol.
112-
The actual gaps are the places the state machine can't reach. Client create failures
113-
are now queryable — when `netcode_client_create` returns NULL,
114-
`netcode_client_create_error()` reports which step failed (address parse, socket
115-
create per family, simulator-requires-port, allocation) — though the finer
116-
`NETCODE_SOCKET_ERROR_*` granularity (bind vs sockopt vs create) still stops inside
117-
the socket layer. The remaining gap is the server: create returning NULL is
118-
undifferentiated, and there is no state-machine equivalent — failures reduce to
119-
`netcode_server_running()` returning false with no why.
120-
121114
**Small sharp edges:**
122115
- Global mutable state (log level, printf/assert hooks, the `netcode_init` reference
123116
count, static timers) means the library is single-threaded by design. The header now
@@ -141,7 +134,9 @@ which runs with a continuing assert handler so it exercises the guards in debug
141134
term independently; a zeroed client/server config gets default allocators instead of
142135
crashing; the network simulator uses a per-instance seeded xorshift64* instead of global
143136
`rand()`, so simulator runs are deterministic — pinned by
144-
test_network_simulator_determinism.)
137+
test_network_simulator_determinism; `netcode_client_create_error()` and
138+
`netcode_server_create_error()` report why create returned NULL, with server bind
139+
failures distinguished per address family.)
145140

146141
**Process gaps.** CI now builds and runs the tests on all three platforms in Debug and
147142
Release, runs an ASan+UBSan leg, and smoke-fuzzes the parsing surface

netcode.c

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3920,6 +3920,13 @@ struct netcode_server_t
39203920
struct netcode_address_t receive_from[NETCODE_SERVER_MAX_RECEIVE_PACKETS];
39213921
};
39223922

3923+
static int server_create_error;
3924+
3925+
int netcode_server_create_error()
3926+
{
3927+
return server_create_error;
3928+
}
3929+
39233930
int netcode_server_socket_create( struct netcode_socket_t * socket,
39243931
struct netcode_address_t * address,
39253932
int send_buffer_size,
@@ -3934,8 +3941,24 @@ int netcode_server_socket_create( struct netcode_socket_t * socket,
39343941
{
39353942
if ( !config->override_send_and_receive )
39363943
{
3937-
if ( netcode_socket_create( socket, address, send_buffer_size, receive_buffer_size ) != NETCODE_SOCKET_ERROR_NONE )
3944+
int socket_error = netcode_socket_create( socket, address, send_buffer_size, receive_buffer_size );
3945+
3946+
if ( socket_error != NETCODE_SOCKET_ERROR_NONE )
39383947
{
3948+
// report bind failures separately: a port already in use is the common
3949+
// operational failure for dedicated servers, and callers want to react
3950+
// to it differently than to a socket that could not be created at all
3951+
3952+
if ( socket_error == NETCODE_SOCKET_ERROR_BIND_IPV4_FAILED || socket_error == NETCODE_SOCKET_ERROR_BIND_IPV6_FAILED )
3953+
{
3954+
server_create_error = ( address->type == NETCODE_ADDRESS_IPV6 ) ? NETCODE_SERVER_CREATE_ERROR_BIND_SOCKET_IPV6_FAILED
3955+
: NETCODE_SERVER_CREATE_ERROR_BIND_SOCKET_IPV4_FAILED;
3956+
}
3957+
else
3958+
{
3959+
server_create_error = ( address->type == NETCODE_ADDRESS_IPV6 ) ? NETCODE_SERVER_CREATE_ERROR_CREATE_SOCKET_IPV6_FAILED
3960+
: NETCODE_SERVER_CREATE_ERROR_CREATE_SOCKET_IPV4_FAILED;
3961+
}
39393962
return 0;
39403963
}
39413964
}
@@ -3949,6 +3972,8 @@ struct netcode_server_t * netcode_server_create_dual( NETCODE_CONST char * serve
39493972
netcode_assert( config );
39503973
netcode_assert( netcode.initialized );
39513974

3975+
server_create_error = NETCODE_SERVER_CREATE_ERROR_NONE;
3976+
39523977
// tolerate a zeroed config: default the allocator functions so a forgotten
39533978
// netcode_default_server_config is an inconvenience, not a crash
39543979

@@ -3968,12 +3993,14 @@ struct netcode_server_t * netcode_server_create_dual( NETCODE_CONST char * serve
39683993
if ( netcode_parse_address( server_address1_string, &server_address1 ) != NETCODE_OK )
39693994
{
39703995
netcode_printf( NETCODE_LOG_LEVEL_ERROR, "error: failed to parse server public address\n" );
3996+
server_create_error = NETCODE_SERVER_CREATE_ERROR_PARSE_ADDRESS_FAILED;
39713997
return NULL;
39723998
}
39733999

39744000
if ( server_address2_string != NULL && netcode_parse_address( server_address2_string, &server_address2 ) != NETCODE_OK )
39754001
{
39764002
netcode_printf( NETCODE_LOG_LEVEL_ERROR, "error: failed to parse server public address2\n" );
4003+
server_create_error = NETCODE_SERVER_CREATE_ERROR_PARSE_ADDRESS2_FAILED;
39774004
return NULL;
39784005
}
39794006

@@ -4017,6 +4044,7 @@ struct netcode_server_t * netcode_server_create_dual( NETCODE_CONST char * serve
40174044
{
40184045
netcode_socket_destroy( &socket_ipv4 );
40194046
netcode_socket_destroy( &socket_ipv6 );
4047+
server_create_error = NETCODE_SERVER_CREATE_ERROR_ALLOCATE_SERVER_FAILED;
40204048
return NULL;
40214049
}
40224050

@@ -6962,6 +6990,70 @@ void test_client_create_error()
69626990
}
69636991
}
69646992

6993+
void test_server_create_error()
6994+
{
6995+
struct netcode_server_config_t server_config;
6996+
netcode_default_server_config( &server_config );
6997+
6998+
// successful create leaves the create error as NONE
6999+
7000+
{
7001+
struct netcode_server_t * server = netcode_server_create( "127.0.0.1:40000", &server_config, 0.0 );
7002+
7003+
check( server );
7004+
check( netcode_server_create_error() == NETCODE_SERVER_CREATE_ERROR_NONE );
7005+
7006+
netcode_server_destroy( server );
7007+
}
7008+
7009+
// bad first address
7010+
7011+
check( netcode_server_create( "not an address", &server_config, 0.0 ) == NULL );
7012+
check( netcode_server_create_error() == NETCODE_SERVER_CREATE_ERROR_PARSE_ADDRESS_FAILED );
7013+
7014+
// bad second address
7015+
7016+
check( netcode_server_create_dual( "127.0.0.1:40000", "not an address", &server_config, 0.0 ) == NULL );
7017+
check( netcode_server_create_error() == NETCODE_SERVER_CREATE_ERROR_PARSE_ADDRESS2_FAILED );
7018+
7019+
// a port already in use is reported as a bind failure, distinct from other socket errors (ipv4)
7020+
7021+
{
7022+
struct netcode_server_t * first_server = netcode_server_create( "127.0.0.1:40000", &server_config, 0.0 );
7023+
7024+
check( first_server );
7025+
7026+
check( netcode_server_create( "127.0.0.1:40000", &server_config, 0.0 ) == NULL );
7027+
check( netcode_server_create_error() == NETCODE_SERVER_CREATE_ERROR_BIND_SOCKET_IPV4_FAILED );
7028+
7029+
netcode_server_destroy( first_server );
7030+
}
7031+
7032+
// and the same over ipv6 reports the ipv6 bind error
7033+
7034+
{
7035+
struct netcode_server_t * first_server = netcode_server_create( "[::1]:40000", &server_config, 0.0 );
7036+
7037+
check( first_server );
7038+
7039+
check( netcode_server_create( "[::1]:40000", &server_config, 0.0 ) == NULL );
7040+
check( netcode_server_create_error() == NETCODE_SERVER_CREATE_ERROR_BIND_SOCKET_IPV6_FAILED );
7041+
7042+
netcode_server_destroy( first_server );
7043+
}
7044+
7045+
// server struct allocation failure
7046+
7047+
{
7048+
struct netcode_server_config_t failing_config;
7049+
netcode_default_server_config( &failing_config );
7050+
failing_config.allocate_function = test_failing_allocate_function;
7051+
7052+
check( netcode_server_create( "127.0.0.1:40000", &failing_config, 0.0 ) == NULL );
7053+
check( netcode_server_create_error() == NETCODE_SERVER_CREATE_ERROR_ALLOCATE_SERVER_FAILED );
7054+
}
7055+
}
7056+
69657057
void test_network_simulator_determinism()
69667058
{
69677059
// the network simulator has its own seeded rng, so two simulators given
@@ -9324,6 +9416,7 @@ void netcode_test()
93249416
RUN_TEST( test_runtime_guards );
93259417
RUN_TEST( test_init_and_defaults );
93269418
RUN_TEST( test_client_create_error );
9419+
RUN_TEST( test_server_create_error );
93279420
RUN_TEST( test_network_simulator_determinism );
93289421
RUN_TEST( test_client_create );
93299422
RUN_TEST( test_server_create );

netcode.h

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,15 @@
100100
#define NETCODE_CLIENT_CREATE_ERROR_CREATE_SOCKET_IPV6_FAILED 5
101101
#define NETCODE_CLIENT_CREATE_ERROR_ALLOCATE_CLIENT_FAILED 6
102102

103+
#define NETCODE_SERVER_CREATE_ERROR_NONE 0
104+
#define NETCODE_SERVER_CREATE_ERROR_PARSE_ADDRESS_FAILED 1
105+
#define NETCODE_SERVER_CREATE_ERROR_PARSE_ADDRESS2_FAILED 2
106+
#define NETCODE_SERVER_CREATE_ERROR_CREATE_SOCKET_IPV4_FAILED 3
107+
#define NETCODE_SERVER_CREATE_ERROR_CREATE_SOCKET_IPV6_FAILED 4
108+
#define NETCODE_SERVER_CREATE_ERROR_BIND_SOCKET_IPV4_FAILED 5
109+
#define NETCODE_SERVER_CREATE_ERROR_BIND_SOCKET_IPV6_FAILED 6
110+
#define NETCODE_SERVER_CREATE_ERROR_ALLOCATE_SERVER_FAILED 7
111+
103112
#define NETCODE_MAX_CLIENTS 256
104113
#define NETCODE_MAX_PACKET_SIZE 1200
105114

@@ -252,6 +261,16 @@ struct netcode_server_t * netcode_server_create( NETCODE_CONST char * server_add
252261

253262
struct netcode_server_t * netcode_server_create_dual( NETCODE_CONST char * server_address1, NETCODE_CONST char * server_address2, NETCODE_CONST struct netcode_server_config_t * config, double time );
254263

264+
/*
265+
If netcode_server_create or netcode_server_create_dual returns NULL, call this to find out why.
266+
Returns the NETCODE_SERVER_CREATE_ERROR_* value from the most recent server create call,
267+
or NETCODE_SERVER_CREATE_ERROR_NONE if that call succeeded. Bind failures are reported
268+
separately from other socket errors because a port already in use is the common
269+
operational failure for dedicated servers.
270+
*/
271+
272+
int netcode_server_create_error();
273+
255274
void netcode_server_destroy( struct netcode_server_t * server );
256275

257276
void netcode_server_start( struct netcode_server_t * server, int max_clients );

0 commit comments

Comments
 (0)