Commit Graph

11 Commits

Author SHA1 Message Date
Vladimir Dubrovin
7963494b1c Event loops: a relayed client's buffers given back while empty
A connection's two data buffers are malloc()'d with it (bufalloc())
instead of being part of struct clientparam, which is CLIENTBUFSIZE*2
smaller. The callbacks' relay of a client whose fiber gave up its stack
frees a side's buffer while it is empty and makes it again to read, and
gives both back before sockmap() or the child's tail goes on: a pool of
idle relayed connections holds no buffers. Fibers and threads keep
theirs as before.

mix scenarios, callbacks (-L -L2), peak memory before/after, throughput
the same:
  Intel: relay 972/632 MB, connect 981/581, socks 992/594
  ARM:   relay 1000/673 MB, connect 1000/615, socks 1020/629

struct clientparam's clibufdata and srvbufdata are pointers now:
plugins built against the old structure are not compatible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:47:24 +03:00
Vladimir Dubrovin
973fc89d58 Event loops: a fiber's stack given back while its client is relayed
A child for which mapping is the last thing it does (tcppm, tlspr) sets
maptail, what it does once mapping is over as its own end would: its
log line, prepared before for tlspr. Relayed by the loop, the client's
fiber then gives up its stack at once: left in the middle of the
child's code, never to be resumed, it is started over from the start of
its stack for the next client (fiberreset), or freed where it cannot be
(Windows). Once mapping is over the loop ends the client as the child
and its fiber would: the result, the tail, the connection released and
freed. After a reload, which sockmap() is to see to, the client is
given a fiber again for sockmap() to go on with the mapping, then its
end. Not while a reload is pending: sockmap() sees to it on the fiber.

The callbacks' state is no longer on the fiber's stack but allocated.

clientparam has maptail and maptaildata: plugins are to be built again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:25:33 +03:00
Vladimir Dubrovin
9cdac1cb60 splice() pipes closed with fdclose(), the loop forgetting them first
sockmap waits on its splice() pipes as on its sockets, and on an event
loop a pipe waited on is registered there like a socket. They were
closed with close(), past the socket functions, so the loop kept the
registration of a closed descriptor, which epoll and kqueue drop on the
close: a socket given the same number later was taken for it, never
registered, and its fiber waited on it forever. fdclose() tells the
loop first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:54:46 +03:00
Vladimir Dubrovin
43f40569d4 Sockets closed without SO_LINGER
Client, outgoing and FTP data sockets, and the inetd socket of a
standalone service, were given SO_LINGER, the client's for the long
string timeout (60 seconds by default), the outgoing one's for the
LINGER timeout. It kept nothing from being lost: without it the system
sends what is queued after the close all the same, and the time running
out reset the connection, losing what was still queued. All it did was
make close() wait for the peer to acknowledge the data, holding the
connection's thread and maxconn slot meanwhile, and, for clients served
on an event loop, every client of the loop: with one loop it was idle a
quarter of the time in a benchmark of many short exchanges.

The LINGER timeout keeps its use: how long a half-closed connection
waits for the rest of its data, and how long httpsrv reads what is left
of a request before closing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:00:48 +03:00
Vladimir Dubrovin
c55f736a0d Clients of TCP services on event loops, served by fibers
With -L, or loopmode on for the services which follow, the clients of a
TCP service are served on event loops: loops threads (by default one
for each CPU) each run a loop, and each client is given in turn to one
of them, which serves it on a fiber with the same code a thread runs.
Its waits in sockpoll() wait in the loop, its sockets and a timer
registered there, and the fiber goes on once one is ready or the time
is out; a sleep is a timer alone. A client costs a stack, as large as
its thread's would be, and no thread. The loops start with the first
client served on one and run as long as the process; each keeps up to
256 idle fibers for its next clients.

srvspawn() posts the client to its loop once it is counted, what is
posted made before so that posting cannot fail. A service whose clients
cannot share a thread (threadperclient), or listening for connections
back (-R), all of its clients waiting for one socket, which a loop
waits for once, keeps a thread for each client, as does every service
of a build or a processor without fibers, and the standalone services.

tests/run.py --loopmode runs every configuration with loopmode on, on
two loops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 23:00:16 +03:00
Vladimir Dubrovin
126c7d00ec Connect back: a connection one client takes leaves the others waiting
The clients of a -R service all wait for the connection back on the
same listening socket, which was blocking: one connection woke them
all, one took it, and the others were left in accept() until the next,
past their time out and through a reload. The socket is non-blocking,
and an accept() which finds nothing, the connection taken by another
client or gone, is not one of the client's tries. nonblock() makes a
socket non-blocking, acceptagain() tells such an accept().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:34:46 +03:00
Vladimir Dubrovin
b4bbcb9e9a Socket functions: _poll becomes _prepoll, which never waits
A layer over the sockets, TLS or a plugin's, no longer waits itself.
Its _prepoll only tells what it knows without asking the system: the
fds it knows to be ready, data TLS has already decrypted, or, returning
0, that the sockets have to be polled, for events it may rewrite: a
TLS read waiting for its socket to be writable. The wait is
sockpoll()'s, which calls _prepoll first and then polls, so that a wait
can be made other than with poll() in one place: a client of an event
loop is to wait in the loop. Whatever a rewritten wait gets is given to
the caller as what it asked for, its chance to try again, and the
events it asked for are given back. With no fds sockpoll() sleeps,
which WSAPoll() would refuse.

TLS remembers what a read or a write it could not do waits for. A read
needing to write, or a write to read, was an error which ended the
connection, as when TLS 1.3 answers a key update while the socket
cannot take more; it now waits for what TLS needs, as one needing what
it was asked for always did.

Incompatible for plugins, which have to be built again with the same
version of 3proxy, the structures they share not kept compatible:
- struct sockfuncs has _prepoll(state, fds, nfds) in place of
  _poll(state, fds, nfds, timeout). A plugin replacing _poll replaces
  _prepoll: it must not wait, returns the number of fds it set revents
  of (events then left as they are), 0 for the fds to be polled (with
  events as it leaves them, revents left to the poll) or -1, and calls
  the _prepoll it replaced when it has nothing to say itself.
- Waiting for sockets is sockpoll(sf, state, fds, nfds, timeout), not
  so._poll() of the socket functions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:54:03 +03:00
Vladimir Dubrovin
2052dc0a4f Logs: a thread and a queue for each log; build-time defaults
Once 3proxy runs (after the first configuration is in force, so after a
daemon's fork), each logger has a thread of its own writing from a
queue, and a client never waits for a log: ODBC, RADIUS, a full disk or
a pipe nobody reads hold up only that log's thread.

A queued line is fixed size: the part of struct clientparam logging
reads (everything before the filters: addresses, counters, names and
credentials, which hold no pointer it follows but srv), the time it was
logged, and its text, 256 bytes inline or a copy of its own if longer.
The thread formats it: struct tm is per call now, timechanged() and
dologname() included, and the type's format() takes the time.

minlogqueue (64) slots are allocated as the thread starts and kept,
maxlogqueue (4096) lines are queued at most. A line which finds the
queue full is dropped and counted on the last one queued; once that is
written, "Log X has its queue full, N records dropped" goes to where
errors go. A log which fails is no longer written to the error log
instead: its lines are dropped and counted while it is retried every
minute, and the count is told when it is open again, or when it was
reopened after failed writes.

A service which ended is stopped at once (srvstop(): sockets, listener,
its clients gone), but freed (srvrelease()) through its log's queue,
behind the lines which refer to it and to its configuration: the event
is part of struct srvparam, so a full queue never drops it. logfinish()
writes what is queued, 10 seconds at most, also as 3proxy exits on Unix.
Standalone services write as before, as each line comes.

LOGQUEUEMIN, LOGQUEUEMAX, DEFAULT_MAXCHILD, DEFAULT_POOLMAX,
DEFAULT_POOLTIMEOUT and POOLPERSERVICE (the automatic thread pool cap
for each service, which was 32 in the code) can be set with -D at build
time.

Tests: a log nobody reads (a FIFO) holds no client up, drops past its
queue and says so, and lines queued for a service a reload removed are
written as it formatted them; /dev/full says what it dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:41:48 +03:00
Vladimir Dubrovin
8cc7d255af connect back: a replaced -r service stops waiting at the reload
A -r service waited up to 60 s for the go-ahead on the connection it
made, without looking at a reload. One replaced by a reload went on
waiting, holding a slot and a thread, and took a client if the far
side's go-ahead came in that time; every reload left another such
connection in the far side's queue. The wait is now a second at a time
(60 s in all, as before): at a reload the service takes a go-ahead
already there, its client going on in the service which replaced it,
and otherwise closes the connection and stops.

The far side (-R) sent its go-ahead to any connection it accepted, and
a closed one cost it a client. A connection the -r side closed, at a
reload or after its wait, is readable before any go-ahead, since that
side sends nothing first: once past the ACL it is passed over, without
counting as one of the tries for as many as the listen queue holds
(1 + maxchild/16), so that connections made to be closed or to send
something cannot keep a client's thread in the loop.

Tests: reattach's far side passes over closed connections as -R does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:28:02 +03:00
Vladimir Dubrovin
aee69bf679 proxy: headers read and rewritten in the connection's buffer
The HTTP proxy read each request into a buffer of 64 KB of its own,
cleared it with memset, and read the answer's headers into it again;
the header lines were copied there one by one from clibuf or srvbuf.
Now the header block of a request is read whole into clibuf, and that
of an answer into srvbuf, and worked on where it is (hdrread()):

- the block is moved to one byte into the buffer, and its lines, as
  they are kept, are moved down to its start, so the line being worked
  on, and the 0 written after it, never reach the next one; headers
  taken out are left behind;
- HDRRESERVE is left free after it, for the headers this proxy adds,
  before whatever came after it, a body or another request, from
  where the connection is read on;
- the buffer is grown only for a block bigger than it, up to the
  limits there were (512 KB for a request, 64 KB for an answer);
- the headers are copied to a buffer of their own only when they need
  more room than that, when a filter hands back one, when a body is to
  be read whole into the same buffer for a data filter, and for an ftp
  listing, which is written where the headers were.

Whether buf is the headers' own buffer is kept with it (bufown), not
found from where it points: the side's buffer may be a bigger one by
then let go of. filterswap() leaves alone a buffer in either side's
buffer, which may be one grown by malloc, not only their fixed arrays.

What took buf for a buffer of LINESIZE does no longer: the request is
logged from a copy of its line, and the rest of a request refused
before its headers were all read is drained through a small buffer of
its own, keeping track of where its lines begin.

Tests: a POST whose body comes in the same packet as its headers,
which are rewritten, with a header added and one taken out; 45 KB of
request headers; two requests pipelined in one packet; empty lines
before a request; an answer with a 30 KB header; a 600 KB header block
refused, the proxy answering after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:49:46 +03:00
Vladimir Dubrovin
5cbc72d289 Split proxymain.c into its parts
proxymain.c stays the one unit built for 3proxy and included by each
standalone service, and now includes its parts:

  mainconf.c	services from their configuration lines: options,
		srvprepare(), mainprepare(), srvinit(), srvfree(), and
		copying and freeing access lists and authentication
  mainlisten.c	listening sockets, shared and taken over on a reload,
		and reattach()
  mainclient.c	threads for the clients of TCP services, their pool,
		srvspawn(), releaseparam(), and the filters' hooks
  maintcp.c	the main loop of a TCP service, tcploop(), taken out of
		srvrun(), with tcpnext() and cblisten()
  mainudp.c	the main loop of a UDP service

proxymain.c keeps srvstart, srvrun() and main(), and declares the
functions of file scope of the parts so that they can be in any order.
The code is moved, not changed, but for tcploop(). The parts are
dependencies of the objects that include proxymain.c in Makefile.inc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:11:46 +03:00