Seregon/zftpd

Zero-copy FTP/HTTP Daemon compatible with all POSIX systems

C/11.0 KB/No license
tests/stress_local.py
zftpd / tests / stress_local.py
1#!/usr/bin/env python3
2"""
3Stress test locale per zftpd (macOS/Linux) che simula il caso "tanti file piccoli + churn".
4 
5Prerequisiti:
6- Server zftpd già in esecuzione su host/porta indicati (default 127.0.0.1:2121).
7- Credenziali FTP valide (default user=test, pass=test).
8 
9Cosa fa:
101. Genera un dataset di molti file piccoli in dir annidate (default /tmp/zftpd-tiny).
112. Avvia un thread di churn che cancella/riscrive file durante il transfer.
123. Esegue RETR paralleli via FTP (ftplib) su tutti i file del dataset.
13 
14Uso tipico (server avviato con root=/tmp):
15 python3 tests/stress_local.py --remote-base /zftpd-tiny
16 
17Se il server ha root impostata su /tmp/zftpd-tiny, usa:
18 python3 tests/stress_local.py --remote-base /
19 
20Parametri:
21 --host, --port, --user, --password : connessione FTP
22 --root : path locale dove creare il dataset
23 --remote-base : prefisso remoto (es. /zftpd-tiny o /)
24 --dirs : numero di sottodirectory (default 8)
25 --files : file per directory (default 400)
26 --concurrency : worker FTP paralleli (default 6)
27 --duration : secondi di churn prima di fermare (default 60)
28"""
29 
30from __future__ import annotations
31 
32import argparse
33import os
34import random
35import string
36import threading
37import time
38from concurrent.futures import ThreadPoolExecutor, as_completed
39from ftplib import FTP
40from pathlib import Path
41from typing import List
42 
43 
44def make_dataset(root: Path, dirs: int, files_per_dir: int) -> List[Path]:
45 root.mkdir(parents=True, exist_ok=True)
46 rels: List[Path] = []
47 for d in range(1, dirs + 1):
48 sub = root / f"dir{d}"
49 sub.mkdir(parents=True, exist_ok=True)
50 for i in range(1, files_per_dir + 1):
51 size = 1024 + random.randint(0, 3072)
52 payload = ("X" * size).encode("ascii")
53 path = sub / f"f{i}.bin"
54 with open(path, "wb") as fh:
55 fh.write(payload)
56 rels.append(path.relative_to(root))
57 return rels
58 
59 
60def churn_worker(root: Path, stop_evt: threading.Event) -> None:
61 files = list(root.rglob("*.bin"))
62 while not stop_evt.is_set():
63 # Cancella una manciata di file e riscrive con contenuto diverso
64 sample = random.sample(files, k=min(25, len(files))) if files else []
65 for p in sample:
66 try:
67 p.unlink(missing_ok=True)
68 except OSError:
69 pass
70 for p in sample:
71 try:
72 payload = ("Y" * (1500 + random.randint(0, 2048))).encode("ascii")
73 p.parent.mkdir(parents=True, exist_ok=True)
74 with open(p, "wb") as fh:
75 fh.write(payload)
76 except OSError:
77 pass
78 time.sleep(0.5)
79 
80 
81def fetch_file(host: str, port: int, user: str, password: str, remote_base: str, relpath: Path) -> str:
82 remote_path = f"{remote_base.rstrip('/')}/{relpath.as_posix()}"
83 # ftplib richiede percorso assoluto: assicurati che inizi con '/'
84 if not remote_path.startswith("/"):
85 remote_path = "/" + remote_path
86 try:
87 with FTP() as ftp:
88 ftp.connect(host=host, port=port, timeout=5)
89 ftp.login(user=user, passwd=password)
90 # usa una sink in memoria
91 ftp.retrbinary(f"RETR {remote_path}", lambda _: None)
92 return "ok"
93 except Exception as exc: # pragma: no cover - diagnosi runtime
94 return f"err:{type(exc).__name__}:{exc}"
95 
96 
97def main() -> int:
98 parser = argparse.ArgumentParser(description="Stress locale zftpd (mirror parziale)")
99 parser.add_argument("--host", default="127.0.0.1")
100 parser.add_argument("--port", type=int, default=2121)
101 parser.add_argument("--user", default="test")
102 parser.add_argument("--password", default="test")
103 parser.add_argument("--root", default="/tmp/zftpd-tiny")
104 parser.add_argument("--remote-base", default="/zftpd-tiny")
105 parser.add_argument("--dirs", type=int, default=8)
106 parser.add_argument("--files", type=int, default=400)
107 parser.add_argument("--concurrency", type=int, default=6)
108 parser.add_argument("--duration", type=int, default=60)
109 args = parser.parse_args()
110 
111 root = Path(args.root)
112 print(f"[prep] dataset in {root} ...")
113 rels = make_dataset(root, args.dirs, args.files)
114 print(f"[prep] generati {len(rels)} file")
115 
116 stop_evt = threading.Event()
117 churn_thr = threading.Thread(target=churn_worker, args=(root, stop_evt), daemon=True)
118 churn_thr.start()
119 
120 results = []
121 start = time.time()
122 with ThreadPoolExecutor(max_workers=args.concurrency) as pool:
123 futs = [pool.submit(fetch_file, args.host, args.port, args.user, args.password, args.remote_base, rel) for rel in rels]
124 for fut in as_completed(futs):
125 results.append(fut.result())
126 if time.time() - start > args.duration:
127 break
128 
129 stop_evt.set()
130 churn_thr.join(timeout=2)
131 
132 ok = sum(1 for r in results if r == "ok")
133 err = [r for r in results if r != "ok"]
134 print(f"[done] ok={ok}, err={len(err)} (durata ~{int(time.time()-start)}s)")
135 if err:
136 print(f"[sample errors] {err[:5]}")
137 return 0 if not err else 1
138 
139 
140if __name__ == "__main__":
141 raise SystemExit(main())
142