mirror of
https://github.com/3proxy/3proxy.git
synced 2026-10-28 22:05:48 +08:00
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>
217 lines
8.3 KiB
Python
217 lines
8.3 KiB
Python
"""Logs: one for each log string, shared by every service naming it, kept
|
|
over a reload while something uses it and closed once nothing does. A log
|
|
which cannot be written passes its lines to standard error."""
|
|
|
|
import os
|
|
import time
|
|
|
|
|
|
def _read(path):
|
|
try:
|
|
with open(path, "rb") as fp:
|
|
return fp.read().decode("utf-8", "replace")
|
|
except OSError:
|
|
return ""
|
|
|
|
|
|
def _proxied(path, mark):
|
|
"""Whether a proxy, not the origin, logged the request marked so."""
|
|
return any(line.startswith("LOG PROXY") and mark in line
|
|
for line in _read(path).splitlines())
|
|
|
|
|
|
def _wait(cond, timeout=10.0):
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
if cond():
|
|
return True
|
|
time.sleep(0.05)
|
|
return cond()
|
|
|
|
|
|
def _opened(pid, path):
|
|
"""How many times the process has the file open, None without /proc."""
|
|
fds = f"/proc/{pid}/fd"
|
|
if not os.path.isdir(fds):
|
|
return None
|
|
n = 0
|
|
for fd in os.listdir(fds):
|
|
try:
|
|
if os.readlink(os.path.join(fds, fd)) == path:
|
|
n += 1
|
|
except OSError:
|
|
pass
|
|
return n
|
|
|
|
|
|
def _stalled(t):
|
|
"""A log nobody reads: a FIFO whose pipe is full. Clients are not held
|
|
up, the lines past the queue are dropped and counted, and lines queued
|
|
for a service a reload removed are still written as it formatted them."""
|
|
fifo = os.path.join(t.tmpdir, "logs-fifo")
|
|
os.mkfifo(fifo)
|
|
# opened for reading before 3proxy opens it for writing, then not read
|
|
rfd = os.open(fifo, os.O_RDONLY | os.O_NONBLOCK)
|
|
origin, port, adm = t.free_port(), t.free_port(), t.free_port()
|
|
|
|
# lines of 2 KB, made so by the service's format: the pipe's 64 KB fill
|
|
# before the requests are done
|
|
pad = "z" * 2000
|
|
|
|
def conf(proxyline):
|
|
return f"""
|
|
log
|
|
maxlogqueue 8
|
|
auth iponly
|
|
allow *
|
|
http data * /data
|
|
httpsrv -p{origin}
|
|
flush
|
|
admin -p{adm}
|
|
flush
|
|
logformat "LQ {pad} %T"
|
|
auth iponly
|
|
allow *
|
|
{proxyline}
|
|
"""
|
|
|
|
server = t.start("logs_stall", conf(f'proxy -p{port} -l{fifo}'),
|
|
ports=[origin, port, adm])
|
|
began = time.time()
|
|
ok = 0
|
|
for i in range(120):
|
|
r = t.http(f"http://127.0.0.1:{origin}/data?size=10&n{i:04d}",
|
|
proxy=f"127.0.0.1:{port}")
|
|
ok += r.status == 200
|
|
t.eq(120, ok, "a log nobody reads holds no client up")
|
|
t.eq(True, time.time() - began < 60, "and they are not slowed to its pace")
|
|
# the service goes: its lines still queued are written as it made them
|
|
t.write_config("logs_stall", conf("flush"))
|
|
t.http(f"http://127.0.0.1:{adm}/R")
|
|
got = b""
|
|
deadline = time.time() + 15
|
|
while time.time() < deadline and "has its queue full" not in server.output():
|
|
try:
|
|
chunk = os.read(rfd, 65536)
|
|
except BlockingIOError:
|
|
chunk = b""
|
|
got += chunk
|
|
if not chunk:
|
|
time.sleep(0.05)
|
|
t.eq(True, "has its queue full" in server.output(),
|
|
"the lines past its queue are dropped, and said so")
|
|
# what is left in the pipe and the queue, read to the end
|
|
deadline = time.time() + 5
|
|
while time.time() < deadline:
|
|
try:
|
|
chunk = os.read(rfd, 65536)
|
|
except BlockingIOError:
|
|
chunk = b""
|
|
if chunk:
|
|
got += chunk
|
|
deadline = time.time() + 1
|
|
else:
|
|
time.sleep(0.05)
|
|
os.close(rfd)
|
|
lines = got.decode("utf-8", "replace").splitlines()
|
|
t.eq(True, 0 < len(lines) < 120, "some lines are written, not all")
|
|
t.eq(True, all(l.startswith("Q " + pad) for l in lines),
|
|
"each as its service formats it, the service gone or not")
|
|
|
|
|
|
def config(origin, p1, p2, p3, adm, main, shared=None):
|
|
"""p2 and p3 log to shared with -l, if it is given, or are not there."""
|
|
lines = [f"log {main}", 'logformat "LLOG %N %T"', "auth iponly", "allow *",
|
|
"http data * /data", f"httpsrv -p{origin}", f"proxy -p{p1}"]
|
|
if shared:
|
|
lines += [f"proxy -p{p2} -l{shared}", f"proxy -p{p3} -l{shared}"]
|
|
lines.append(f"admin -p{adm}")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def run(t):
|
|
origin, p1, p2, p3, adm = (t.free_port() for _ in range(5))
|
|
first = os.path.join(t.tmpdir, "logs-first.log")
|
|
second = os.path.join(t.tmpdir, "logs-second.log")
|
|
shared = os.path.join(t.tmpdir, "logs-shared.log")
|
|
|
|
server = t.start("logs", config(origin, p1, p2, p3, adm, first, shared),
|
|
ports=[origin, p1, p2, p3, adm])
|
|
pid = server.proc.pid
|
|
|
|
def get(port, mark):
|
|
return t.http(f"http://127.0.0.1:{origin}/data?size=10&{mark}",
|
|
proxy=f"127.0.0.1:{port}").status
|
|
|
|
# --- the log command and -l ----------------------------------------
|
|
t.eq(200, get(p1, "one"), "a request through the first proxy")
|
|
t.eq(200, get(p2, "two"), "one through the second")
|
|
t.eq(200, get(p3, "three"), "one through the third")
|
|
t.eq(True, _wait(lambda: _proxied(first, "one")), "the log command's file gets its line")
|
|
t.eq(True, _wait(lambda: _proxied(shared, "two") and _proxied(shared, "three")),
|
|
"the two services with the same -l write to the same file")
|
|
t.eq(False, _proxied(first, "two"), "and not to the log command's")
|
|
t.eq(False, _proxied(shared, "one"), "nor the other way round")
|
|
n = _opened(pid, shared)
|
|
if n is not None:
|
|
t.eq(1, n, "the file named by both is opened once")
|
|
t.eq(1, _opened(pid, first), "the log command's file is open")
|
|
|
|
def reload(main, until):
|
|
t.write_config("logs", config(origin, p1, p2, p3, adm, main))
|
|
t.http(f"http://127.0.0.1:{adm}/R")
|
|
return _wait(until)
|
|
|
|
# --- a reload to another file: the old one is closed once unused -----
|
|
t.eq(True, reload(second, lambda: "Accepting connections" in _read(second)),
|
|
"the new configuration logs to the new file")
|
|
t.eq(200, get(p1, "four"), "a request after the reload")
|
|
t.eq(True, _wait(lambda: _proxied(second, "four")), "goes to the new file")
|
|
t.eq(False, _proxied(first, "four"), "not to the old one")
|
|
if n is not None:
|
|
t.eq(True, _wait(lambda: _opened(pid, first) == 0),
|
|
"the old file is closed once its services are gone")
|
|
t.eq(True, _wait(lambda: _opened(pid, shared) == 0),
|
|
"and so is the file of -l nobody names any more")
|
|
t.eq(1, _opened(pid, second), "the new one is open")
|
|
|
|
# --- the same file, rotated daily: reopened under the day's name -----
|
|
dated = second + "." + time.strftime("%Y.%m.%d")
|
|
t.eq(True, reload(second + " D", lambda: os.path.exists(dated)),
|
|
"a rotation given to the log reopens it under the dated name")
|
|
t.eq(200, get(p1, "five"), "a request after that")
|
|
t.eq(True, _wait(lambda: _proxied(dated, "five")), "goes to the dated file")
|
|
t.eq(False, _proxied(second, "five"), "not to the undated one")
|
|
|
|
# --- a log which fails: its lines dropped, and said so -----------------
|
|
if os.path.exists("/dev/full"):
|
|
o2, q = t.free_port(), t.free_port()
|
|
full = t.start("logs_full", f"""
|
|
log /dev/full
|
|
logformat "LLOG %N %T"
|
|
auth iponly
|
|
allow *
|
|
http data * /data
|
|
httpsrv -p{o2}
|
|
proxy -p{q}
|
|
""", ports=[o2, q])
|
|
# a buffer's worth, at least, before writing it fails
|
|
for i in range(80):
|
|
t.http(f"http://127.0.0.1:{o2}/data?size=10&{'x' * 40}{i}",
|
|
proxy=f"127.0.0.1:{q}")
|
|
t.eq(True, _wait(lambda: "Log /dev/full could not be written" in full.output()),
|
|
"a log a full device refuses says so on standard error")
|
|
t.eq(True, "records dropped" in full.output(), "with the records it dropped")
|
|
t.eq(False, "LOG PROXY" in full.output(), "its lines are not written elsewhere")
|
|
|
|
# --- a log which stalls: clients go on, the queue is bounded ----------
|
|
if hasattr(os, "mkfifo"):
|
|
_stalled(t)
|
|
|
|
# --- a log which cannot be opened is an error in the configuration ---
|
|
out = t.run_config("logs_bad", f"""
|
|
log {os.path.join(t.tmpdir, "no", "such", "dir", "x.log")}
|
|
proxy -p{t.free_port()}
|
|
""")
|
|
t.contains(out, "x.log", "a log which cannot be opened is named")
|