3proxy/tests/cases/unixsock.py
Vladimir Dubrovin 5c19e6368f Unix sockets: remove on closing only the file this socket bound
When a reload cannot take a listening Unix socket over (a bind-time
-ol option changed), the new service binds the path anew, and the old
listener, closing after it, removed the file, the new socket's by then,
leaving nothing to connect to. The socket's file (device and inode) is
now noted on binding and the path removed on closing only if it is
still that file.

structures.h includes <stddef.h> for offsetof() in SASIZE(), which
needs it wherever the header is used.

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

184 lines
6.9 KiB
Python

"""Unix domain sockets: a service listening on one (-iunix:PATH, and on
Linux an abstract -iunix:@NAME), tcppm forwarding to one (DSTADDR
unix:PATH), a parent reached through one (parent ... unix:PATH), and a
listening one across reloads: taken over, its file kept while a service
listens on it and removed once none does."""
import os
import socket
import sys
import threading
import time
def unix_get(path, url, timeout=10):
"""A GET for url sent to the proxy at the Unix socket path (a leading @
for an abstract one): (status, body length), or (None, error)."""
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(timeout)
try:
s.connect("\0" + path[1:] if path.startswith("@") else path)
s.sendall(f"GET {url} HTTP/1.0\r\nHost: unix\r\n\r\n".encode())
got = b""
while True:
b = s.recv(65536)
if not b:
break
got += b
except OSError as e:
return None, str(e)
finally:
s.close()
head, _, body = got.partition(b"\r\n\r\n")
try:
return int(head.split()[1]), len(body)
except (IndexError, ValueError):
return None, head[:80]
def run(t):
if not hasattr(socket, "AF_UNIX") or sys.platform.startswith("win"):
t.skip("Unix domain sockets (not on this platform)")
return
origin = t.free_port()
mapped = t.free_port()
chained = t.free_port()
adm = t.free_port()
path = os.path.join(t.tmpdir, "px.sock")
abstract = f"@3proxy-test-{os.getpid()}" if sys.platform.startswith("linux") else None
url = f"http://127.0.0.1:{origin}/data?size=1000"
def config(unix=True):
services = f"""
flush
auth iponly
allow *
proxy -iunix:{path}
""" if unix else ""
if unix and abstract:
services += f"""
proxy -iunix:{abstract}
"""
if unix:
services += f"""
flush
auth iponly
allow *
tcppm {mapped} unix:{path} 0
flush
auth iponly
allow *
parent 1000 http unix:{path} 0
proxy -p{chained}
"""
return f"""
log
auth iponly
allow *
http data * /data
httpsrv -p{origin}
{services}
flush
auth iponly
allow *
admin -p{adm}
"""
server = t.start("unixsock", config(), ports=[origin, mapped, chained, adm])
# a build without Unix sockets (NO_UN) never makes the file
deadline = time.time() + 5
while time.time() < deadline and not os.path.exists(path):
time.sleep(0.05)
if not os.path.exists(path):
t.skip("Unix domain sockets (not supported by this build)")
return
# --- a proxy listening on a Unix socket ---------------------------------
t.eq((200, 1000), unix_get(path, url), "a proxy listening on a Unix socket answers")
if abstract:
t.eq((200, 1000), unix_get(abstract, url),
"a proxy listening on an abstract Unix socket answers")
# --- tcppm forwarding to a Unix socket: the proxy behind it answers -----
r = t.http(url, proxy=f"127.0.0.1:{mapped}")
t.eq(200, r.status, "tcppm forwards a TCP port to a Unix socket")
t.eq(1000, r.length, "and the whole answer comes back through it")
# --- a parent reached through a Unix socket ------------------------------
r = t.http(url, proxy=f"127.0.0.1:{chained}")
t.eq(200, r.status, "a proxy chains to a parent on a Unix socket")
t.eq(1000, r.length, "and the whole answer comes back through the chain")
# --- reloads: the socket is taken over, its file stays ------------------
done = {"ok": 0, "failed": 0, "errors": []}
stop = threading.Event()
def client():
while not stop.is_set():
got = unix_get(path, url)
if got == (200, 1000):
done["ok"] += 1
else:
done["failed"] += 1
if len(done["errors"]) < 3:
done["errors"].append(got)
time.sleep(0.02)
clients = [threading.Thread(target=client) for _ in range(3)]
for c in clients:
c.start()
mark = len(server.output())
# the log names a Unix socket by its file's name
taken_line = " px.sock:0 0.0.0.0:0 0 0 0 Accepting connections, taken over"
for i in range(3):
time.sleep(0.5)
t.write_config("unixsock", config())
t.http(f"http://127.0.0.1:{adm}/R")
# one reload done before the next is asked for
deadline = time.time() + 10
while time.time() < deadline and server.output()[mark:].count(taken_line) < i + 1:
time.sleep(0.05)
# the old services stop within a second of each reload
time.sleep(1.5)
stop.set()
for c in clients:
c.join(20)
t.ok(f"requests over the Unix socket go on through the reloads ({done['ok']})") \
if done["ok"] > 20 else \
t.fail("requests over the Unix socket go on through the reloads", "more than 20", done["ok"])
t.eq(0, done["failed"], "none is refused or reset by a reload "
f"(first errors: {done['errors']})")
taken = server.output()[mark:].count(taken_line)
t.eq(3, taken, "the Unix socket is taken over on each reload, not opened anew")
t.ok("its file is still there") if os.path.exists(path) else \
t.fail("its file is still there", path, "missing")
t.eq((200, 1000), unix_get(path, url), "and the proxy answers on it")
# --- a bind-time option changed: a new socket on the same path, and
# the old one, closing after, must not remove the new one's file -------
t.write_config("unixsock", config().replace(f"proxy -iunix:{path}",
f"proxy -olSO_REUSEADDR=0 -iunix:{path}"))
t.http(f"http://127.0.0.1:{adm}/R")
# the old services stop within a second of the reload
time.sleep(2)
t.ok("a new socket on the same path keeps its file when the old one closes") \
if os.path.exists(path) else \
t.fail("a new socket on the same path keeps its file when the old one closes", path, "missing")
t.eq((200, 1000), unix_get(path, url), "and the proxy answers on it")
# --- no service listens there any more: the file goes -------------------
t.write_config("unixsock", config(unix=False))
t.http(f"http://127.0.0.1:{adm}/R")
deadline = time.time() + 5
while time.time() < deadline and os.path.exists(path):
time.sleep(0.1)
t.ok("once no service listens on it the socket file is removed") \
if not os.path.exists(path) else \
t.fail("once no service listens on it the socket file is removed", "no file", path)
got = unix_get(path, url, timeout=2)
t.ok("and nothing answers there") if got[0] is None else \
t.fail("and nothing answers there", "no connection", got)