yum/3ner
A toon shader for Unity's BIRP.
git clone https://git.yummers.dev/yum/3ner
50e4514
master
1#!/usr/bin/env python3 2"""Spawn multiple ffmpeg readers against a single HLS playlist. 3 4Usage: 5python load_test.py https://example/hls/abc/stream.m3u8 5 6 7The positional arguments control the target playlist URL and how many 8concurrent ffmpeg processes are started. Each client retries on failure with a 9short backoff so it behaves like a persistent video player. A health summary is 10printed periodically showing how many workers are streaming, buffering, or 11backing off. When the script exits (normally or via Ctrl+C) all spawned ffmpeg 12children are terminated. 13""" 14 15from __future__import annotations 16 17import argparse 18import os 19import random 20import signal 21import subprocess 22import sys 23import tempfile 24import time 25from collections import deque 26from dataclasses import dataclass 27 28 29STOP_REQUESTED = False 30BUFFER_KEYWORDS = ( 31"buffer" , 32"stall" , 33"timeout" , 34"timed out" , 35"drop" , 36"late" , 37"overrun" , 38"input/output error" , 39"connection refused" , 40) 41SUMMARY_INTERVAL = float (os .environ .get ("LOAD_TEST_SUMMARY_INTERVAL" ,"5" )) 42STABLE_AFTER = float (os .environ .get ("LOAD_TEST_STABLE_AFTER" ,"5" )) 43LOG_CHECK_INTERVAL = float (os .environ .get ("LOAD_TEST_LOG_CHECK_INTERVAL" ,"2" )) 44MIN_BACKOFF = float (os .environ .get ("LOAD_TEST_MIN_BACKOFF" ,"0.5" )) 45MAX_BACKOFF = float (os .environ .get ("LOAD_TEST_MAX_BACKOFF" ,"30" )) 46 47 48def parse_args ()-> argparse .Namespace : 49parser = argparse .ArgumentParser ( 50description = "Spawn N concurrent ffmpeg readers against an HLS playlist." 51 ) 52parser .add_argument ( 53"playlist_url" , 54help = "HLS playlist URL to probe." 55 ) 56parser .add_argument ( 57"count" , 58type = int , 59help = "Number of concurrent ffmpeg processes to launch." 60 ) 61return parser .parse_args () 62 63 64@dataclass 65class Client : 66index :int 67command :list [str ] 68attempts :int = 0 69process :subprocess .Popen [str ]| None = None 70log_path :str | None = None 71resume_at :float = 0.0 72last_pid :int | None = None 73start_time :float = 0.0 74is_buffering :bool = False 75last_warning :str | None = None 76last_error :str | None = None 77state :str = "idle" 78last_log_check :float = 0.0 79consecutive_failures :int = 0 80 81def spawn (self )-> None : 82log_handle = tempfile .NamedTemporaryFile ( 83prefix = f"hls_client_ { self . index } _" , 84suffix = ".log" , 85delete = False , 86mode = "w" , 87encoding = "utf-8" , 88 ) 89 90try : 91proc = subprocess .Popen ( 92self .command , 93stdout = subprocess .DEVNULL , 94stderr = log_handle , 95text = False , 96 ) 97except FileNotFoundError :# pragma: no cover - requires missing ffmpeg 98sys .exit ("ffmpeg binary not found; install ffmpeg before running this script." ) 99finally : 100log_handle .close () 101 102self .attempts += 1 103self .process = proc 104self .log_path = log_handle .name 105self .resume_at = 0.0 106self .last_pid = proc .pid 107self .start_time = time .time () 108self .is_buffering = False 109self .last_warning = None 110self .state = "starting" 111self .last_log_check = 0.0 112f"Spawned client { self . index } attempt { self . attempts } (pid= { proc . pid } )" 114 ) 115 116def poll (self )-> int | None : 117if self .process is None : 118return None 119return self .process .poll () 120 121def terminate (self )-> None : 122if self .process and self .process .poll ()is None : 123self .process .terminate () 124 125 126def spawn_clients (target_url :str ,count :int )-> list [Client ]: 127command = [ 128"ffmpeg" , 129"-nostdin" , 130"-loglevel" , 131os .environ .get ("FFMPEG_LOGLEVEL" ,"warning" ), 132"-i" , 133target_url , 134"-f" , 135"null" , 136"-" , 137 ] 138 139clients :list [Client ]= [] 140for index in range (1 ,count + 1 ): 141client = Client (index = index ,command = command .copy ()) 142client .spawn () 143clients .append (client ) 144 145return clients 146 147 148def terminate_processes (clients :list [Client ])-> None : 149for client in clients : 150client .terminate () 151 152deadline = time .time ()+ 5 153for client in clients : 154proc = client .process 155if proc and proc .poll ()is None : 156remaining = max (0 ,deadline - time .time ()) 157try : 158proc .wait (timeout = remaining ) 159except subprocess .TimeoutExpired : 160proc .kill () 161 162 163def install_signal_handlers (clients :list [Client ])-> None : 164def handler (signum :int ,_frame )-> None : 165global STOP_REQUESTED 166if STOP_REQUESTED : 167return 168STOP_REQUESTED = True 169f"Received signal { signum } , shutting down clients…" ) 170terminate_processes (clients ) 171cleanup_logs (clients ) 172sys .exit (0 ) 173 174signal .signal (signal .SIGINT ,handler ) 175signal .signal (signal .SIGTERM ,handler ) 176 177 178def explain_exit_code (return_code :int | None )-> str : 179if return_code is None : 180return "process still running" 181if return_code == 0 : 182return "exited cleanly" 183 184signal_num :int | None = None 185if return_code < 0 : 186signal_num = - return_code 187elif return_code >= 128 : 188signal_num = return_code - 128 189 190if signal_num : 191try : 192sig_name = signal .Signals (signal_num ).name 193except ValueError : 194sig_name = f"signal { signal_num } " 195return f"terminated by { sig_name } (exit code { return_code } )" 196 197return f"exited with status { return_code } " 198 199 200def read_log_tail (path :str ,max_lines :int = 5 )-> str : 201try : 202with open (path ,"r" ,encoding = "utf-8" ,errors = "replace" )as handle : 203tail_lines = '' .join (deque (handle ,maxlen = max_lines )).strip () 204return tail_lines or "<no log output>" 205except OSError as exc : 206return f"<unable to read log: { exc } >" 207 208 209def report_exit (client :Client ,return_code :int | None )-> None : 210log_path = client .log_path 211description = explain_exit_code (return_code ) 212pid_info = f"pid { client . last_pid } " if client .last_pid is not None else "pid unknown" 213f"Client { client . index } ( { pid_info } ) { description } ." ) 214if log_path : 215log_tail = read_log_tail (log_path ) 216if log_tail : 217"Recent log:" ) 218log_tail ) 219client .last_error = log_tail if (return_code and log_tail )else None 220try : 221os .remove (log_path ) 222except OSError : 223pass 224client .log_path = None 225client .is_buffering = False 226client .last_warning = None 227 228 229def cleanup_logs (clients :list [Client ])-> None : 230for client in clients : 231if client .log_path : 232report_exit (client ,client .process .returncode if client .process else None ) 233 234 235def compute_backoff (failures :int )-> float : 236if failures <= 0 : 237return MIN_BACKOFF 238 239min_backoff = max (0.0 ,MIN_BACKOFF ) 240max_backoff = max (min_backoff ,MAX_BACKOFF ) 241 242lower = min (max_backoff ,min_backoff * (2 ** (failures - 1 ))) 243upper = min (max_backoff ,min_backoff * (2 ** failures )) 244 245if upper < lower : 246lower ,upper = upper ,lower 247 248if lower == upper : 249return lower 250 251return random .uniform (lower ,upper ) 252 253 254def truncate (text :str ,limit :int = 120 )-> str : 255return text if len (text )<= limit else text [:limit - 3 ]+ "..." 256 257 258def detect_buffering (client :Client ,now :float )-> None : 259if not client .log_path : 260client .is_buffering = False 261return 262 263if now - client .last_log_check < LOG_CHECK_INTERVAL : 264return 265 266client .last_log_check = now 267tail = read_log_tail (client .log_path ) 268if tail and tail != "<no log output>" : 269lower_tail = tail .lower () 270if any (keyword in lower_tail for keyword in BUFFER_KEYWORDS ): 271client .is_buffering = True 272client .last_warning = tail 273return 274 275client .is_buffering = False 276 277 278def print_summary (clients :list [Client ],now :float )-> None : 279counts = { 280"streaming" :0 , 281"buffering" :0 , 282"starting" :0 , 283"backoff" :0 , 284"idle" :0 , 285 } 286 287buffering_notes = [] 288recent_errors = [] 289 290for client in clients : 291proc = client .process 292if proc and proc .poll ()is None : 293uptime = now - client .start_time 294detect_buffering (client ,now ) 295if client .is_buffering : 296client .state = "buffering" 297elif uptime >= STABLE_AFTER : 298client .state = "streaming" 299if client .consecutive_failures : 300client .consecutive_failures = 0 301client .last_error = None 302else : 303client .state = "starting" 304else : 305if STOP_REQUESTED : 306client .state = "idle" 307elif client .resume_at > now : 308client .state = "backoff" 309else : 310client .state = "idle" 311 312counts .setdefault (client .state ,0 ) 313counts [client .state ]+= 1 314 315if client .is_buffering and client .last_warning : 316buffering_notes .append ( 317f"# { client . index } { truncate ( client . last_warning ) } " 318 ) 319if client .last_error and client .state != "streaming" : 320recent_errors .append ( 321f"# { client . index } { truncate ( client . last_error ) } " 322 ) 323 324restarts = sum (max (0 ,client .attempts - 1 )for client in clients ) 325summary_parts = [ 326f"Streaming: { counts [ 'streaming' ] } " , 327f"Buffering: { counts [ 'buffering' ] } " , 328f"Starting: { counts [ 'starting' ] } " , 329f"Backoff: { counts [ 'backoff' ] } " , 330f"Idle: { counts [ 'idle' ] } " , 331f"Restarts: { restarts } " , 332 ] 333 334"[Summary] " + " | " .join (summary_parts )) 335 336if buffering_notes : 337" Buffering clients:" ) 338for note in buffering_notes : 339f" { note } " ) 340 341if recent_errors : 342" Recent errors:" ) 343for error in recent_errors [:5 ]: 344f" { error } " ) 345 346 347def monitor (clients :list [Client ])-> None : 348global STOP_REQUESTED 349try : 350next_summary = time .time ()+ SUMMARY_INTERVAL 351while True : 352now = time .time () 353for client in clients : 354proc = client .process 355if proc and proc .poll ()is not None : 356return_code = proc .returncode 357report_exit (client ,return_code ) 358client .process = None 359if STOP_REQUESTED : 360continue 361 362if return_code and return_code != 0 : 363client .consecutive_failures += 1 364backoff = compute_backoff (client .consecutive_failures ) 365else : 366client .consecutive_failures = 0 367backoff = MIN_BACKOFF 368client .resume_at = now + backoff 369f"Scheduling restart for client { client . index } in { backoff :.1f } s…" 371 ) 372client .state = "backoff" 373 374if client .process is None and not STOP_REQUESTED and now >= client .resume_at : 375client .spawn () 376 377if now >= next_summary : 378print_summary (clients ,now ) 379next_summary = now + SUMMARY_INTERVAL 380 381if STOP_REQUESTED : 382break 383 384time .sleep (1 ) 385finally : 386STOP_REQUESTED = True 387terminate_processes (clients ) 388cleanup_logs (clients ) 389 390 391def main ()-> None : 392args = parse_args () 393if args .count < 1 : 394sys .exit ("Count must be at least 1." ) 395 396clients = spawn_clients (args .playlist_url ,args .count ) 397install_signal_handlers (clients ) 398"Press Ctrl+C to stop all clients." ) 399monitor (clients ) 400 401 402if __name__ == "__main__" : 403main ()