3proxy/tests/cases/logs.py
Vladimir Dubrovin 235f899668
Some checks failed
C/C++ CI Linux / ${{ matrix.target }} (ubuntu-24.04-arm) (push) Has been cancelled
C/C++ CI Linux / ${{ matrix.target }} (ubuntu-latest) (push) Has been cancelled
C/C++ CI MacOS / ${{ matrix.target }} (macos-15) (push) Has been cancelled
C/C++ CI Windows / ${{ matrix.target }} (windows-2022) (push) Has been cancelled
C/C++ CI cmake / ${{ matrix.target }} (macos-15) (push) Has been cancelled
C/C++ CI cmake / ${{ matrix.target }} (ubuntu-24.04-arm) (push) Has been cancelled
C/C++ CI cmake / ${{ matrix.target }} (ubuntu-latest) (push) Has been cancelled
C/C++ CI cmake / ${{ matrix.target }} (windows-2022) (push) Has been cancelled
C/C++ CI cmake / ubuntu-latest (wolfSSL) (push) Has been cancelled
help: the features of the build listed, and the tests skip what they need
3proxy --help (and the usage message of a bad invocation) now lists
what the build was made with: TLS and its backend, RADIUS, PCRE, IPv6,
UNIX sockets, the built-in HTTP server, transparency, ODBC and the
optional proxy services. The test runner reads the list before the
first case and the cases state the features they need with
t.skip_unless(), so a build without a feature skips its cases instead
of failing them; a binary too old to report anything skips the cases
with needs and runs the rest.
2026-10-30 14:17:54 +03:00

219 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):
if not t.skip_unless("httpsrv"):
return
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")