Zero-copy FTP/HTTP Daemon compatible with all POSIX systems
| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | Stress test locale per zftpd (macOS/Linux) che simula il caso "tanti file piccoli + churn". |
| 4 | |
| 5 | Prerequisiti: |
| 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 | |
| 9 | Cosa fa: |
| 10 | 1. Genera un dataset di molti file piccoli in dir annidate (default /tmp/zftpd-tiny). |
| 11 | 2. Avvia un thread di churn che cancella/riscrive file durante il transfer. |
| 12 | 3. Esegue RETR paralleli via FTP (ftplib) su tutti i file del dataset. |
| 13 | |
| 14 | Uso tipico (server avviato con root=/tmp): |
| 15 | python3 tests/stress_local.py --remote-base /zftpd-tiny |
| 16 | |
| 17 | Se il server ha root impostata su /tmp/zftpd-tiny, usa: |
| 18 | python3 tests/stress_local.py --remote-base / |
| 19 | |
| 20 | Parametri: |
| 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 | |
| 30 | from __future__ import annotations |
| 31 | |
| 32 | import argparse |
| 33 | import os |
| 34 | import random |
| 35 | import string |
| 36 | import threading |
| 37 | import time |
| 38 | from concurrent.futures import ThreadPoolExecutor, as_completed |
| 39 | from ftplib import FTP |
| 40 | from pathlib import Path |
| 41 | from typing import List |
| 42 | |
| 43 | |
| 44 | def 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 | |
| 60 | def 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 | |
| 81 | def 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 | |
| 97 | def 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 | |
| 140 | if __name__ == "__main__": |
| 141 | raise SystemExit(main()) |
| 142 |