mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-17 08:02:43 +02:00
Only big files (>=8 MiB) used OpenSSH; games made mostly of small files stayed on Paramiko and were slow on a fast link. native_upload.put_tar streams the same tar batch through system ssh with the paired key and pinned host key; on any failure the install falls back to Paramiko's put_tar and logs it, and doesn't retry OpenSSH for later batches. The job log now names the transport for each part. upload_bench gains app-small / paramiko-small comparisons.
420 lines
21 KiB
Python
420 lines
21 KiB
Python
"""Compare bulk transports on one paired Frame endpoint and one destination filesystem.
|
||
|
||
uv run python scripts/upload_bench.py --mib 512 --repeat 3 --json /tmp/uploads.json
|
||
uv run python scripts/upload_bench.py --fast-link --remote-dir /path/on/sdcard --check-resume
|
||
|
||
The script uses a unique scratch folder, verifies SHA-256 outside the timed
|
||
transfer, and removes its own files afterwards. Native SCP/SFTP timings include startup; SSH streaming reports
|
||
handshake separately. Windows-native Python and WSL must be measured separately.
|
||
Use --methods app-files-1,app-files-2,sftp-files-1,sftp-files-2 to compare parallel file uploads.
|
||
Both file methods accept worker counts 1, 2, 3, 4 and 8, using the same eight-file payload.
|
||
app-files-N uses the production backend (OpenSSH preferred, Paramiko fallback); its log records the actual choice.
|
||
Use --methods app-small,paramiko-small to compare production tar batches with the same small-file payload.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import contextlib
|
||
import csv
|
||
import hashlib
|
||
import io
|
||
import json
|
||
import os
|
||
import platform
|
||
import random
|
||
import shutil
|
||
import stat
|
||
import subprocess
|
||
import sys
|
||
import tarfile
|
||
import tempfile
|
||
import time
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||
|
||
import paramiko # noqa: E402
|
||
|
||
from frameport.frame.connection import Frame, saved_targets, sh_quote, ssh_dir # noqa: E402
|
||
|
||
|
||
@contextlib.contextmanager
|
||
def private_key():
|
||
"""Temporary private copy; never alter the user's pairing key/ACL."""
|
||
key = ssh_dir() / "id_ed25519"
|
||
if os.name != "nt" and stat.S_IMODE(key.stat().st_mode) & 0o077 == 0:
|
||
yield str(key)
|
||
return
|
||
with tempfile.TemporaryDirectory(prefix="frameport-bench-key-") as directory:
|
||
copy = Path(directory) / "id_ed25519"
|
||
if os.name == "nt":
|
||
row = next(csv.reader(io.StringIO(subprocess.check_output(
|
||
["whoami", "/user", "/fo", "csv", "/nh"], text=True))))
|
||
# Restrict the directory before writing the key, including under inherited Windows ACLs.
|
||
subprocess.run(["icacls", directory, "/inheritance:r", "/grant:r", f"*{row[-1]}:(OI)(CI)F"],
|
||
check=True, stdout=subprocess.DEVNULL)
|
||
shutil.copyfile(key, copy)
|
||
if os.name == "nt":
|
||
# Python 3.13's chmod can add an OWNER RIGHTS ACE that Windows OpenSSH refuses.
|
||
subprocess.run(["icacls", str(copy), "/inheritance:r", "/remove:g", "*S-1-3-4",
|
||
"/grant:r", f"*{row[-1]}:F"], check=True, stdout=subprocess.DEVNULL)
|
||
else:
|
||
copy.chmod(0o600)
|
||
yield str(copy)
|
||
|
||
|
||
def ssh_base(frame, key, port_flag="-p"):
|
||
return ["-i", key, "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes", "-o", "ConnectTimeout=10",
|
||
"-o", f"UserKnownHostsFile={ssh_dir() / 'known_hosts'}", "-o", "StrictHostKeyChecking=yes",
|
||
port_flag, str(frame.target.port)]
|
||
|
||
|
||
def cpu():
|
||
t = os.times()
|
||
return t.user + t.system + t.children_user + t.children_system
|
||
|
||
|
||
def digest(path):
|
||
with open(path, "rb") as source:
|
||
return hashlib.file_digest(source, "sha256").hexdigest()
|
||
|
||
|
||
def sftp_quote(path):
|
||
"""OpenSSH batch syntax, distinct from shell quoting."""
|
||
value = str(path)
|
||
if any(c in value for c in "\n\r\x00"):
|
||
raise ValueError("SFTP paths cannot contain newlines or NUL")
|
||
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||
|
||
|
||
def native(frame, key, tool="ssh"):
|
||
return [tool, *ssh_base(frame, key, "-p" if tool == "ssh" else "-P"),
|
||
f"{frame.target.user}@{frame.target.host}"]
|
||
|
||
|
||
def stream_ssh(frame, key, command, writer, cipher=None):
|
||
args = native(frame, key)
|
||
if cipher:
|
||
args[1:1] = ["-c", cipher]
|
||
t0 = time.monotonic()
|
||
# Drain stderr to a file: no pipe deadlock for long-running transfers or an authentication failure.
|
||
with tempfile.TemporaryFile() as errors:
|
||
process = subprocess.Popen([*args, f"printf 'READY\\n'; {command}"], stdin=subprocess.PIPE,
|
||
stdout=subprocess.PIPE, stderr=errors)
|
||
try:
|
||
if process.stdout.readline() != b"READY\n":
|
||
process.wait(timeout=15)
|
||
errors.seek(0)
|
||
raise RuntimeError(errors.read().decode(errors="replace")[-1000:])
|
||
setup = time.monotonic() - t0
|
||
writer(process.stdin)
|
||
process.stdin.close()
|
||
if process.wait(timeout=60):
|
||
errors.seek(0)
|
||
raise RuntimeError(errors.read().decode(errors="replace")[-1000:])
|
||
return setup
|
||
finally:
|
||
if process.poll() is None:
|
||
process.kill()
|
||
process.wait()
|
||
process.stdout.close()
|
||
if not process.stdin.closed:
|
||
process.stdin.close()
|
||
|
||
|
||
def send(method, frame, local, remote, key, small, large=None, logs=None):
|
||
"""Returns measured setup seconds (None when the native tool cannot expose handshake completion)."""
|
||
t0 = time.monotonic()
|
||
if method in ("app-small", "paramiko-small") or method.startswith(("app-files-", "paramiko-files-")):
|
||
from frameport.core.events import Reporter
|
||
from frameport.install.installer import upload_files
|
||
|
||
workers = 1 if method.endswith("-small") else int(method.rsplit("-", 1)[1])
|
||
with contextlib.closing(Frame(frame.target, frame.password).connect()) as transfer:
|
||
items = [(path, path.name, path.stat().st_size) for path in (small if method.endswith("-small") else large)]
|
||
def record(event):
|
||
if event.kind == "log":
|
||
if logs is not None:
|
||
logs.append(event.message)
|
||
print(f" {event.message}", flush=True)
|
||
reporter = Reporter([record])
|
||
upload_files(transfer, items, remote, reporter, sum(size for _, _, size in items), workers=workers,
|
||
prefer_native=not method.startswith("paramiko-"))
|
||
return None # worker handshakes overlap transfers; measure the production end-to-end operation
|
||
if method.startswith("sftp-files-"):
|
||
workers = min(int(method.rsplit("-", 1)[1]), len(large))
|
||
frame.run(f"mkdir -p {sh_quote(remote)}")
|
||
groups = [[] for _ in range(workers)]
|
||
for path in sorted(large, key=lambda p: p.stat().st_size, reverse=True):
|
||
group = min(groups, key=lambda group: sum(p.stat().st_size for p in group))
|
||
group.append(path)
|
||
def upload(group):
|
||
args = native(frame, key, "sftp")
|
||
args[-1:-1] = ["-b", "-"]
|
||
commands = "".join(f"put {sftp_quote(path)} {sftp_quote(remote + '/' + path.name)}\n"
|
||
for path in group)
|
||
subprocess.run(args, input=commands.encode(), check=True, stdout=subprocess.DEVNULL, timeout=1800)
|
||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||
list(pool.map(upload, groups))
|
||
return None # each worker keeps one native SFTP session for its entire batch
|
||
if method in ("app", "app-tar"):
|
||
with contextlib.closing(Frame(frame.target).connect()) as transfer:
|
||
setup = time.monotonic() - t0
|
||
if method == "app":
|
||
transfer.put(local, remote)
|
||
else:
|
||
transfer.put_tar([(p, p.name) for p in small], remote)
|
||
return setup
|
||
if method == "paramiko-tuned":
|
||
# Authenticate the same paired host key, rather than opening an unchecked raw Transport.
|
||
with contextlib.closing(Frame(frame.target).connect()) as transfer:
|
||
transport = transfer.client.get_transport()
|
||
with paramiko.SFTPClient.from_transport(transport, window_size=64 << 20) as client:
|
||
setup = time.monotonic() - t0
|
||
with open(local, "rb") as source, client.open(remote, "wb") as target:
|
||
target.set_pipelined(True)
|
||
shutil.copyfileobj(source, target, 1 << 20)
|
||
return setup
|
||
if method.startswith("ssh"):
|
||
cipher = {"ssh-gcm": "aes128-gcm@openssh.com", "ssh-chacha": "chacha20-poly1305@openssh.com"}.get(method)
|
||
if method == "ssh-tar":
|
||
def writer(pipe):
|
||
with tarfile.open(fileobj=pipe, mode="w|", format=tarfile.PAX_FORMAT) as archive:
|
||
for path in small:
|
||
archive.add(path, arcname=path.name, recursive=False)
|
||
command = f"mkdir -p {sh_quote(remote)} && tar -xf - -C {sh_quote(remote)}"
|
||
else:
|
||
def writer(pipe):
|
||
with open(local, "rb") as source:
|
||
shutil.copyfileobj(source, pipe, 1 << 20)
|
||
command = f"cat > {sh_quote(remote)}"
|
||
return stream_ssh(frame, key, command, writer, cipher)
|
||
if method == "sftp":
|
||
args = native(frame, key, "sftp")
|
||
args[-1:-1] = ["-b", "-"]
|
||
subprocess.run(args, input=f"put {sftp_quote(local)} {sftp_quote(remote)}\n".encode(),
|
||
check=True, stdout=subprocess.DEVNULL, timeout=1800)
|
||
elif method == "scp":
|
||
args = native(frame, key, "scp")
|
||
destination = args.pop()
|
||
subprocess.run([*args, str(local), f"{destination}:{remote}"], check=True, timeout=1800)
|
||
else:
|
||
raise ValueError(f"unknown method {method}")
|
||
return None
|
||
|
||
|
||
METHODS = ("app", "paramiko-tuned", "ssh", "ssh-gcm", "ssh-chacha", "sftp", "scp", "app-tar", "ssh-tar",
|
||
"app-files-1", "app-files-2", "app-files-3", "app-files-4", "app-files-8",
|
||
"paramiko-files-1", "paramiko-files-2", "paramiko-files-3", "paramiko-files-4", "paramiko-files-8",
|
||
"sftp-files-1", "sftp-files-2", "sftp-files-3", "sftp-files-4", "sftp-files-8",
|
||
"app-small", "paramiko-small")
|
||
|
||
|
||
def check_resume(frame, local, remote, key):
|
||
"""Interrupt the app mid-upload, then resume exactly that .part with app and native SFTP reput."""
|
||
class Interrupted(Exception):
|
||
pass
|
||
|
||
results = []
|
||
for method in ("app", "sftp"):
|
||
if method == "sftp" and not shutil.which("sftp"):
|
||
results.append({"method": method, "skipped": "sftp unavailable"})
|
||
continue
|
||
path = remote + "-" + method
|
||
def interrupt(done, size):
|
||
if done >= size // 2:
|
||
raise Interrupted()
|
||
try:
|
||
frame.put(local, path, interrupt)
|
||
except Interrupted:
|
||
pass
|
||
offset = frame.sftp.stat(path + ".part").st_size
|
||
if not 0 < offset < local.stat().st_size:
|
||
raise RuntimeError("interrupted upload did not leave a partial file")
|
||
started = time.monotonic()
|
||
if method == "app":
|
||
frame.put(local, path)
|
||
else:
|
||
args = native(frame, key, "sftp")
|
||
args[-1:-1] = ["-b", "-"]
|
||
part = path + ".part"
|
||
commands = f"reput {sftp_quote(local)} {sftp_quote(part)}\nrename {sftp_quote(part)} {sftp_quote(path)}\n"
|
||
subprocess.run(args, input=commands.encode(), check=True, timeout=1800, stdout=subprocess.DEVNULL)
|
||
code, out, err = frame.run(f"sha256sum {sh_quote(path)}")
|
||
if code or out.split()[0] != digest(local):
|
||
raise RuntimeError(f"resume checksum mismatch: {err}")
|
||
results.append({"method": method, "offset": offset, "seconds": time.monotonic() - started,
|
||
"sha256_ok": True, "cancelled_app": True})
|
||
from frameport.core.events import Cancelled, Reporter
|
||
from frameport.install.installer import upload_files
|
||
|
||
root = remote + "-production"
|
||
items = [(local, "resume.obb", local.stat().st_size)]
|
||
report = Reporter()
|
||
report.subscribe(lambda event: report.cancelled.set()
|
||
if event.kind == "progress" and event.fraction >= 0.5 else None)
|
||
try:
|
||
upload_files(frame, items, root, report, local.stat().st_size, workers=1)
|
||
except Cancelled:
|
||
pass
|
||
offset = frame.sftp.stat(root + "/resume.obb.part").st_size
|
||
if not 0 < offset < local.stat().st_size:
|
||
raise RuntimeError("production cancellation did not leave a partial file")
|
||
started = time.monotonic()
|
||
logs = []
|
||
upload_files(frame, items, root, Reporter([lambda event: logs.append(event.message)
|
||
if event.kind == "log" else None]), local.stat().st_size, workers=1)
|
||
code, out, err = frame.run(f"sha256sum {sh_quote(root + '/resume.obb')}")
|
||
if code or out.split()[0] != digest(local):
|
||
raise RuntimeError(f"production resume checksum mismatch: {err}")
|
||
results.append({"method": "production", "offset": offset, "seconds": time.monotonic() - started,
|
||
"sha256_ok": True, "cancelled_production": True, "logs": logs})
|
||
return results
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||
parser.add_argument("--mib", type=int, default=512)
|
||
parser.add_argument("--repeat", type=int, default=3)
|
||
parser.add_argument("--small-files", type=int, default=1024)
|
||
parser.add_argument("--host")
|
||
parser.add_argument("--fast-link", action="store_true")
|
||
parser.add_argument("--remote-dir", help="destination filesystem, default: paired user's home")
|
||
parser.add_argument("--methods", default=",".join(METHODS[:9]))
|
||
parser.add_argument("--check-resume", action="store_true")
|
||
parser.add_argument("--json", type=Path)
|
||
parser.add_argument("--probe", action="store_true", help="show reachable links and storage without transfers")
|
||
args = parser.parse_args()
|
||
methods = args.methods.split(",")
|
||
if args.mib < 1 or args.repeat < 1 or not 1 <= args.small_files <= 10000 or set(methods) - set(METHODS):
|
||
parser.error("positive size/repeat and 1–10000 small files required; methods: " + ",".join(METHODS))
|
||
targets = saved_targets()
|
||
if not targets:
|
||
parser.error("pair a Frame first")
|
||
target = targets[0]
|
||
if args.host:
|
||
target.host = args.host
|
||
report = {"platform": platform.platform(), "python": sys.version, "paramiko": paramiko.__version__,
|
||
"wsl": "microsoft" in platform.release().lower(), "methods": [], "resume": [],
|
||
"cpu_scope": "Python parent only on Windows; parent + child processes on POSIX"}
|
||
with contextlib.ExitStack() as stack:
|
||
control = stack.enter_context(contextlib.closing(Frame(target).connect()))
|
||
frame, label = control.fast_link() if args.fast_link else (control, "explicit/current endpoint")
|
||
if frame is not control:
|
||
stack.callback(frame.close)
|
||
report.update(host=frame.target.host, port=frame.target.port, link=label or "home/current endpoint",
|
||
addresses=control.addresses())
|
||
_, inventory, _ = frame.run("lsblk -J -o NAME,TYPE,FSTYPE,MOUNTPOINTS")
|
||
_, wireless, _ = frame.run("iw dev wlan0 link 2>/dev/null")
|
||
report.update(storage_inventory=inventory, wireless=wireless)
|
||
if args.probe:
|
||
print(json.dumps(report, indent=2))
|
||
return 0
|
||
# One unique directory on the chosen filesystem; safely quote user-supplied locations.
|
||
parent = args.remote_dir or frame.home
|
||
code, scratch, err = frame.run(f"mktemp -d -p {sh_quote(parent)} frameport-bench.XXXXXXXX")
|
||
if code:
|
||
raise RuntimeError(err)
|
||
scratch = scratch.strip()
|
||
if not scratch or not scratch.startswith(parent.rstrip("/") + "/frameport-bench."):
|
||
raise RuntimeError("unexpected scratch directory")
|
||
stack.callback(frame.run, f"rm -rf -- {sh_quote(scratch)}")
|
||
_, storage, _ = frame.run(f"df -T {sh_quote(scratch)}")
|
||
report.update(destination=parent, storage=storage.strip())
|
||
key = stack.enter_context(private_key())
|
||
folder = Path(stack.enter_context(tempfile.TemporaryDirectory(prefix="frameport-upload-bench-")))
|
||
local = folder / "payload.bin"
|
||
# No compression-friendly repeated block: each method sends exactly the same random payload.
|
||
with local.open("wb") as output:
|
||
for _ in range(args.mib):
|
||
output.write(os.urandom(1 << 20))
|
||
expected = digest(local)
|
||
small_dir = folder / "small"
|
||
small_dir.mkdir()
|
||
small = []
|
||
with local.open("rb") as source:
|
||
for i in range(args.small_files):
|
||
path = small_dir / f"item-{i:05}.bin"
|
||
source.seek((i * 65536) % local.stat().st_size)
|
||
path.write_bytes(source.read(65536))
|
||
small.append(path)
|
||
small_hashes = {p.name: digest(p) for p in small}
|
||
large_dir = folder / "large"
|
||
large_dir.mkdir()
|
||
large = []
|
||
# Equal total bytes to the single-file trial; all worker counts send exactly these eight files.
|
||
with local.open("rb") as source:
|
||
for i in range(8):
|
||
path = large_dir / f"main-{i:02}.obb"
|
||
count = local.stat().st_size // 8 if i < 7 else local.stat().st_size - source.tell()
|
||
with path.open("wb") as output:
|
||
remaining = count
|
||
while remaining:
|
||
block = source.read(min(remaining, 1 << 20))
|
||
output.write(block)
|
||
remaining -= len(block)
|
||
large.append(path)
|
||
large_hashes = {p.name: digest(p) for p in large}
|
||
print(f"{platform.system()} {'WSL' if report['wsl'] else 'native'} "
|
||
f"to {frame.target.host}, {parent}", flush=True)
|
||
rng = random.Random(0)
|
||
for repeat in range(args.repeat):
|
||
order = list(methods)
|
||
rng.shuffle(order)
|
||
for method in order:
|
||
row = {"method": method, "repeat": repeat + 1}
|
||
tool = ("ssh" if method.startswith("ssh") else "sftp" if method.startswith("sftp-files-")
|
||
else method if method in ("scp", "sftp") else None)
|
||
if tool and not shutil.which(tool):
|
||
row["skipped"] = f"no {tool} on PATH"
|
||
report["methods"].append(row)
|
||
continue
|
||
remote = f"{scratch}/{method}-{repeat}"
|
||
size = (sum(p.stat().st_size for p in small) if method.endswith(("-tar", "-small"))
|
||
else local.stat().st_size)
|
||
c0, t0 = cpu(), time.monotonic()
|
||
try:
|
||
row["logs"] = []
|
||
setup = send(method, frame, local, remote, key, small, large, row["logs"])
|
||
elapsed, used = time.monotonic() - t0, cpu() - c0
|
||
row.update(bytes=size, total_s=elapsed, setup_s=setup, cpu_s=used,
|
||
total_MB_s=size / elapsed / 1e6,
|
||
payload_MB_s=size / (elapsed - setup) / 1e6 if setup is not None else None)
|
||
verify_start = time.monotonic()
|
||
large_trial = method.startswith(("app-files-", "paramiko-files-", "sftp-files-"))
|
||
if method.endswith(("-tar", "-small")) or large_trial:
|
||
pattern = "*.obb" if large_trial else "*.bin"
|
||
_, out, _ = frame.run(f"cd {sh_quote(remote)} && sha256sum {pattern}")
|
||
actual = {line.split(maxsplit=1)[1].strip(): line.split()[0] for line in out.splitlines()}
|
||
verified = actual == (large_hashes if large_trial else small_hashes)
|
||
else:
|
||
code, out, _ = frame.run(f"sha256sum {sh_quote(remote)}")
|
||
verified = code == 0 and bool(out.split()) and out.split()[0] == expected
|
||
row.update(verify_s=time.monotonic() - verify_start, sha256_ok=verified)
|
||
if not verified:
|
||
raise RuntimeError("checksum mismatch")
|
||
print(f"{repeat + 1} {method:15} {size / elapsed / 1e6:7.1f} MB/s total "
|
||
f"{elapsed:6.2f}s setup={setup if setup is not None else 'included'} "
|
||
f"CPU={used:.2f}s checksum=OK",
|
||
flush=True)
|
||
except Exception as exc:
|
||
row["error"] = str(exc)
|
||
print(f"{method}: {exc}", flush=True)
|
||
report["methods"].append(row)
|
||
# Exclude deletion and hash verification from transfer timings and retain no previous trial files.
|
||
frame.run(f"rm -rf -- {sh_quote(remote)} {sh_quote(remote + '.part')}")
|
||
if args.check_resume:
|
||
try:
|
||
report["resume"] = check_resume(frame, local, f"{scratch}/resume", key)
|
||
except Exception as exc:
|
||
report["resume"] = [{"error": str(exc)}]
|
||
print(f"resume check: {exc}", flush=True)
|
||
if args.json:
|
||
args.json.write_text(json.dumps(report, indent=2) + "\n")
|
||
return int(any(r.get("error") for r in [*report["methods"], *report["resume"]]))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|