Skip to content

aowlspt_net.h

Source: abi/aowlspt_net.h — 2881 lines, 71 file-scope functions.

What this header owns

Reproduced verbatim from the header's own banner comment — these notes are frequently the only written record of why the subsystem is shaped the way it is.

text
 aowlspt_net.h — sockets and zlib for the backend server.

nimony's `nativesocket` is a 26-line stub and `winlean` has no socket
surface at all, so the syscalls live here, like every other place this repo
has to reach something nimony cannot. Everything above the byte framing —
parsing, routing, the database, the mod API, and every byte that goes back
on the wire — is nimony.

The zlib part is not optional decoration. Tarkov's client sends and expects
**zlib-framed bodies**: a request body arrives deflated and a response must
be deflated too. A server that answers in plain JSON gets a client that
silently fails to parse it, and `curl` against such a server appears to work
while the game does not — which is exactly the trap `tools/aowlprobe.nim`
exists to document, and why it frames its own bodies rather than shelling out
to `curl`. (This named `tools/SptProbe`, which has never existed in this
repo; the C# tool in `tools/SptReflect` is a metadata dumper and speaks no
protocol at all.)

------------------------------------------------------------------------
Why this is a poller and a request pool rather than a pool of accept
threads
------------------------------------------------------------------------

It used to be a fixed pool of threads that each called `accept` on the
shared listening socket and then stayed with the accepted connection until
the client let go of it. That is the simplest thing that works, and it has
one property that cannot be fixed by tuning it: **the pool size is the
connection limit**. Keep-alive means a connection lives for a session, so a
client that opens sixteen sockets and says nothing on any of them owns the
server. `fuzzwire` demonstrated it with twelve: every other client waited
ten seconds. Bounded receive deadlines cut that to five and raising the pool
from eight to sixteen doubled the number of sockets it took, but both are
the same trade — a bigger number for a bounded attacker, and the attacker
picks the number.

What removes it rather than raising it is decoupling *having a connection*
from *having a thread*:

  * One **poller** thread owns the listening socket and every connection
    that is not currently being served. It waits in `WSAPoll`, accepts, and
    reads whatever has arrived into that connection's buffer. It never runs
    a route and never blocks on one socket.
  * A connection becomes interesting only when a **complete request** is
    buffered — headers and the whole declared body. Then it is pushed onto a
    ready queue and one of the **request workers** picks it up, calls into
    nimony to answer it, and gives the connection back to the poller.

So a connection that stalls, dribbles, or opens and says nothing never
reaches a worker at all. It costs one socket, one `AowlConn` (about a
hundred bytes; it was 80 before the websocket fields went in, and that is a
hand count off the struct rather than a `sizeof` anybody has printed) and
whatever it has actually managed to send — nothing is allocated
for a body it merely *claims* it will send. The limit is
`AOWL_NET_MAX_CONNS` sockets and `AOWL_NET_MAX_BUFFERED` bytes of buffered
request, not a thread count.

------------------------------------------------------------------------
WSAPoll, not IOCP — and what that costs
------------------------------------------------------------------------

IOCP is the other way to write this and is the better one for a server with
thousands of concurrent sockets spread over many cores. This is not that
server. It listens on loopback, for one game, and the realistic connection
count is single digits with a hostile one in the low hundreds.

What IOCP would cost here:

  * Every read becomes a posted `WSARecv` with an `OVERLAPPED` and a
    completion picked up somewhere else, which means the "have I got a whole
    request yet" state machine stops being a loop you can read top to bottom
    and becomes a set of callbacks with a lifetime problem attached to each
    buffer. The bug class that follows — a completion arriving for a
    connection that has just been closed — is exactly the kind this file
    must not have, because a crash here is the whole game server.
  * Cancellation and shutdown get harder: `CancelIoEx` plus draining the
    port, rather than "close the listening socket and set a flag".

What WSAPoll costs, honestly:

  * The poll set is rebuilt and scanned linearly every wakeup, so the poller
    is O(connections) per tick. At `AOWL_NET_MAX_CONNS` = 1024 that is a
    1024-entry array scan on a thread that is otherwise asleep; it is
    nothing next to inflating a request.
  * There is one poller, so reads are serialised. Reads are a `memcpy` out
    of the kernel; the work that is not — inflate, route, deflate — is on
    the worker pool, which is where the parallelism was already.
  * `WSAPoll` has a documented wart: it never reports `POLLOUT` for a socket
    with a connection in progress. Nothing here polls for writability on a
    connecting socket — the only `POLLWRNORM` wait is in
    `aowl_net_send_within`, reached from `aowl_net_send` and from
    `aowl_ws_send`, and in both cases on an already-established connection
    — so it does not bite.

------------------------------------------------------------------------
Why the byte framing is in C when everything else is nimony
------------------------------------------------------------------------

The poller has to answer one question to do its job: *how many bytes are one
request*. That is the read path, which is what this file is for, and doing
it here keeps the buffer and the loop that fills it in one place.

It is deliberately the **only** HTTP knowledge in this file, and it is
advisory. `aowlspt_nim_handle` re-parses the same bytes and is the authority
on what the answer is; every case where the two could disagree — a second,
disagreeing `Content-Length`, a header block that never terminates, a
declared body over the limit — is a case nimony answers and **closes the
connection on**. So a framing disagreement can never desynchronise a
kept-alive stream: there is no next request to get wrong. No status line,
header or body is written from C.

------------------------------------------------------------------------
The websocket, and why the split falls where it does
------------------------------------------------------------------------

The game opens `/client/notifier/getwebsocket/<session>` and expects a
websocket it can be *pushed* to: new mail, an insurance return, a flea offer
sold. That used to be impossible here for a structural reason rather than a
protocol one — a held connection took one of a fixed pool of accept threads,
so four players idling in the menu owned the server — and the poller removed
exactly that. A connection nobody is answering now costs a socket and its
buffer. So a held websocket is a thing this shape can afford, and
`mods/tarkov` no longer has to fake it with a poll *for a client that
upgrades*. The poll route is still registered and still the fallback: a
client that has not upgraded, has not finished logging in, or has just lost
its connection gets `ErrNotFound` from `notifyPush` and is drained through
`/client/notifier/getwebsocket` instead. See `mods/tarkov/emu/notify.nim`,
which tries the socket first and falls back on purpose.

**The trap is the completeness rule above.** A connection reaches a worker
when a *complete request* is buffered, and a websocket is precisely the
connection on which that never happens again: after the handshake the bytes
on the wire are frames, not requests, and `aowl_conn_complete` would sit at
false forever while the header scan ran to `AOWL_HEAD_CAP` and the body
deadline expired underneath it. Getting that wrong does not fail loudly — it
rebuilds the starvation this file was rewritten to remove, one socket at a
time. So a websocket connection is taken *out* of the request state machine
at the moment it is adopted (`c->ws`), is never pushed onto the ready queue
again, never consults `aowl_conn_complete`, and gets a deadline that treats
silence as health rather than as a stall.

What is in C, and what is not:

  * **Frame boundaries are here**, for the same reason request boundaries
    are: the poller has to answer "how many bytes are one frame" to know
    whether it has one, and the buffer and the loop that fills it are here.
    With it come the read-path refusals, because each of them is a decision
    about whether to *keep reading* — a payload length over
    `AOWL_WS_MAX_FRAME` is refused off the length field, before a byte is
    reserved for it, and an unmasked client frame is refused outright: RFC
    6455 requires a client to mask, and a server that tolerates its absence
    is a server that has stopped checking.
  * **The handshake is not here.** It is `Sec-WebSocket-Key` + the
    well-known GUID + SHA-1 + base64, and nimony ships both `std/sha1` and
    `std/base64`; writing them again in C would be a second implementation
    of two solved things in the language this repo uses least.
  * **No frame is composed here either**, which is the same rule the rest of
    this file already keeps: every byte the server puts on a socket is
    written by nimony. The poller detects a ping, a close or a violation and
    calls `aowlspt_nim_ws_control`; nimony builds the pong, the close or the
    refusal and hands it back to `aowl_ws_send`, which owns only the lock and
    the socket.

`aowl_ws_send` exists because a push comes from a worker (a mod answering a
request) or a timer, while the reads come from the poller, and two threads
writing frames onto one socket interleave into a stream neither side can
parse. One critical section covers every websocket write and the close of a
websocket connection, so a socket is never closed out from under a send in
flight.

**The poller does not wait on it, on any path.** Not on the close, where it
has always used `TryEnterCriticalSection` and deferred to its next wakeup,
and not on the write, where it used to. That is a rule about which thread is
asking rather than about which call it makes, so it is enforced where the
thread is known — `aowl_ws_send` compares the calling thread against the
poller's — and not at the call sites, which are several frames inside nimony
composing a pong. A worker still waits, up to `AOWL_WS_SEND_MS` per stall, because a
worker is a thread whose job is to wait for one client.

What that rule is worth is visible in what it forbids. This lock is one lock
for every websocket, and a worker holds it across a `send` to a peer that
may not be reading — so a blocking poller was one non-reading notifier
client away from stopping the *server*: no accepts, no reads on any
connection, no deadlines, until that push gave up. And it does not give up
at `AOWL_WS_SEND_MS`: that deadline restarts on every byte the peer accepts,
so a client dribbling one byte at a time holds it open indefinitely. A held
connection stalling the whole server is precisely the failure the poller was
written to remove, rebuilt one lock lower down.

A frame the poller cannot write immediately is queued on the connection
(`AOWL_WS_PEND_CAP`) and goes out at the next wakeup, `AOWL_WS_RETRY_MS`
later; a close it cannot make is retried on the same clock. Nothing is
dropped for being unlucky with a lock.

Constants

  • AOWLSPT_NET_H
  • AOWL_BODY_CAP
  • AOWL_BODY_MS
  • AOWL_CONN_CHUNK
  • AOWL_HEAD_CAP
  • AOWL_HEAD_MS
  • AOWL_HOT_GRACE_MS
  • AOWL_HOT_READS
  • AOWL_IDLE_MS
  • AOWL_KEEPALIVE_MAX
  • AOWL_LEN_BAD
  • AOWL_NET_MAX_BUFFERED
  • AOWL_NET_MAX_CONNS
  • AOWL_NET_WORKERS
  • AOWL_SEND_DEADLINE_MS
  • AOWL_SESSION_ID_MAX
  • AOWL_SESSION_SLOTS
  • AOWL_SESSION_SPINS
  • AOWL_SESSION_WAIT_MS
  • AOWL_WS_CLOSE_PROTOCOL
  • AOWL_WS_CLOSE_TOO_BIG
  • AOWL_WS_IDLE_MS
  • AOWL_WS_MAX_FRAME
  • AOWL_WS_MAX_MESSAGE
  • AOWL_WS_OUT_CAP
  • AOWL_WS_PEND_CAP
  • AOWL_WS_RETRY_MS
  • AOWL_WS_SEND_MS

Functions

SignatureLine
int32_t aowl_net_startup(void)285
int32_t aowl_net_last_error(void)290
uint64_t aowl_net_bind(int32_t port)317
int32_t aowl_net_listen_on(uint64_t sock, int32_t backlog)394
uint64_t aowl_net_listen(int32_t port, int32_t backlog)403
uint64_t aowl_net_accept(uint64_t server)415
int32_t aowl_net_recv(uint64_t sock, void* buf, int32_t len)420
int32_t aowl_net_send_within(uint64_t sock, const void* buf, int32_t len, int32_t withinMs)434
int32_t aowl_net_send(uint64_t sock, const void* buf, int32_t len)477
void aowl_net_close(uint64_t sock)481
void aowl_net_shutdown_recv(uint64_t sock)485
uint32_t aowl_session_hash(const char* p, int32_t len)558
int32_t aowl_session_slot(const char* id, int32_t len)573
int32_t aowl_session_lock(const void* idp, int32_t len)627
void aowl_session_unlock(int32_t slot)645
void aowl_ssl_for_sock(SOCKET s)919
void aowl_net_set_nonblocking(SOCKET s)956
int aowl_wake_open(void)961
void aowl_wake_poller(void)983
void aowl_wake_drain(void)988
void aowl_ws_free_msg(AowlConn* c)999
void aowl_ws_free_out(AowlConn* c)1013
void aowl_conn_free_buf(AowlConn* c)1023
int32_t aowl_conn_take(SOCKET s)1034
void aowl_conn_drop(int32_t idx)1072
int aowl_conn_reserve(AowlConn* c, int32_t want)1095
int32_t aowl_ci_match(const char* p, int32_t n, const char* lit)1125
int32_t aowl_scan_length(const char* head, int32_t n)1156
int aowl_scan_upgrade(const char* head, int32_t n)1204
void aowl_frame(AowlConn* c)1230
int aowl_conn_complete(AowlConn* c)1270
int32_t aowl_conn_want(AowlConn* c)1277
int aowl_conn_read(AowlConn* c)1289
ULONGLONG aowl_conn_deadline(AowlConn* c, ULONGLONG now)1315
int64_t aowl_ws_adopt(uint64_t sock)1368
int32_t aowl_ws_write_nowait(SOCKET s, const char* p, int32_t len)1433
int32_t aowl_ws_defer(AowlConn* c, const char* p, int32_t len)1449
int aowl_ws_flush_pending(AowlConn* c)1461
int aowl_ws_enqueue_out(AowlConn* c, const char* p, int32_t len)1483
int aowl_ws_flush_out(AowlConn* c)1513
int32_t aowl_ws_send(int64_t ticket, const void* buf, int32_t len)1560
int aowl_ws_pump(AowlConn* c)1639
void aowl_push_ready(int32_t idx)1867
int32_t aowl_pop_ready(void)1874
void aowl_push_handback(int32_t idx)1886
int aowl_conn_read_hot(AowlConn* c)1912
int aowl_conn_final(void)1967
int aowl_serve_one(AowlConn* c)1973
void aowl_ws_drop_locked(int32_t idx)2026
int aowl_ws_drop(int32_t idx)2055
void aowl_ws_drop_blocking(int32_t idx)2061
DWORD WINAPI aowl_net_worker(LPVOID param)2066
void aowl_watch_remove(int32_t at)2184
void aowl_net_accept_from(SOCKET listener, int useTls, ULONGLONG now)2196
DWORD WINAPI aowl_net_poller(LPVOID param)2237
int32_t aowl_net_reserve(int32_t port)2507
int32_t aowl_net_listen_plain(int32_t port)2528
int32_t aowl_net_port_holder(int32_t port, char* out, int32_t outLen)2565
int32_t aowl_net_serve_impl(int32_t port, int32_t workers)2637
int32_t aowl_net_serve(int32_t port, int32_t workers)2709
int32_t aowl_net_serve_tls(int32_t port, int32_t workers, const char* certPath, const char* keyPath)2720
int32_t aowl_net_tls_ready(void)2734
char aowl_net_tls_error(void)2735
int32_t aowl_net_tls_error_len(void)2736
int32_t aowl_net_tls_gencert(const char* openssl, const char* certPath, const char* keyPath)2740
void aowl_net_stop(void)2745
int32_t aowl_net_running(void)2780
int32_t aowl_zlib_bound(int32_t srcLen)2788
int32_t aowl_zlib_deflate(const void* src, int32_t srcLen, void* dst, int32_t dstCap)2827
int32_t aowl_zlib_inflate(const void* src, int32_t srcLen, void* dst, int32_t dstCap)2860
int32_t aowl_zlib_looks_framed(const void* p, int32_t len)2873

aoughwl — self-hosted platform for things n stuff. Contact / Support on Discord for access to the private backends.