using System.Diagnostics; namespace ErsatzTV.Core.FFmpeg; /// /// How finely a cold-start's startup work could be broken down (#472). /// public enum ColdStartStartupSplitKind { /// /// No split available: the FFmpeg process was never launched, the playlist never appeared, or the /// playlist was observed before FFmpeg was launched (a stale playlist left behind because the /// pre-session transcode-folder wipe failed — it logs a warning and continues). /// Unavailable = 0, /// /// Two-way split: prep + ffmpegInit, because FFmpeg emitted no progress output at all /// before the playlist appeared. ffmpegInit therefore runs to the playlist. /// TwoWay = 1, /// /// Two-way split, distinguished because FFmpeg did report progress but only after the /// playlist was observed. Same buckets as ; kept separate because it means the /// playlist appeared before the first progress report rather than FFmpeg being silent, which is a /// different story about the pipeline (and is also what the 100ms playlist poll can manufacture). /// TwoWayLateProgress = 2, /// Three-way split: prep + ffmpegInit + firstGop. ThreeWay = 3 } /// /// Sub-split of the HLS cold-start startup work (#472), which #350's measurement showed to be 81% of /// tune-in latency and to carry 100% of its variance while remaining a single opaque bucket. /// /// is ErsatzTV-side work before FFmpeg exists: playout-item resolution, pipeline /// build, graphics-engine spawn. is FFmpeg from launch until it first reports /// progress — input open + probe (the NFS hypothesis) plus decoder/encoder init (the VAAPI-contention /// hypothesis). is from that first progress report until live.m3u8 exists. /// /// /// These buckets span the session worker's Run entry to the playlist appearing, which is NOT /// exactly the logged startup phase: the worker is launched fire-and-forget slightly before /// the request thread starts the startup stopwatch, so overlaps the tail of /// the logged setup bucket (in practice one config read). Do not expect /// prep + ffmpegInit + firstGop to equal startup — it is a superset by that overlap. /// /// /// Because the pipeline runs -loglevel error -nostats -hide_banner, a healthy FFmpeg writes /// nothing to stderr, so input-open and encoder-init cannot be separated from each other without /// changing the FFmpeg command — which this instrumentation deliberately does not do. The /// -progress stream on stdout is therefore the only zero-cost milestone available, and /// necessarily lumps those two candidates together. #472 accepts this: a /// large vs a large is itself the first discrimination, /// and it is honest about what it cannot yet see. /// /// /// Three further caveats when reading these numbers. The playlist is detected by a 100ms poll, so its /// timestamp is up to 100ms late and that error lands entirely in — the /// smallest bucket — and can also flip a sample between /// and . And if the session's first FFmpeg /// process fails and a second one produces the playlist, spans the first /// process's whole lifetime plus the retry while still being labelled as one process's init. And the /// stale-playlist guard below is best-effort rather than a proof: if the folder wipe failed, whether /// the stale playlist is observed before or after the launch milestone is a scheduling race, so an /// unlucky sample could still slip through as an implausibly fast one (most often /// , since FFmpeg has usually not reported progress /// that early). /// /// public readonly record struct ColdStartStartupSplit( TimeSpan Prep, TimeSpan FFmpegInit, TimeSpan FirstGop, ColdStartStartupSplitKind Kind) { public static readonly ColdStartStartupSplit Unavailable = new(TimeSpan.Zero, TimeSpan.Zero, TimeSpan.Zero, ColdStartStartupSplitKind.Unavailable); /// /// Builds the split from four milestones; 0 means the /// milestone never happened. Milestones are recorded on different threads (the session worker /// records the launch and progress ones; the request thread observes the playlist), so ordering is /// validated rather than assumed: any out-of-order or missing milestone degrades the result to a /// coarser instead of producing a negative or invented bucket. /// public static ColdStartStartupSplit FromTimestamps( long runStarted, long processLaunched, long firstProgress, long playlistExists) { if (runStarted <= 0 || processLaunched <= 0 || playlistExists <= 0) { return Unavailable; } if (processLaunched > playlistExists) { // the playlist was observed before FFmpeg was even launched, so it is a stale file: the // handler wipes the transcode folder before starting the session, but that wipe swallows // its failures into a warning (LocalFileSystem.EmptyFolder) and continues. // Every bucket would be meaningless; report nothing rather than a plausible-looking sample return Unavailable; } // the worker's Run entry strictly precedes every later milestone; clamp anyway so a clock // oddity can never surface as a negative duration in telemetry TimeSpan prep = Elapsed(runStarted, processLaunched); if (firstProgress <= 0 || firstProgress < processLaunched) { // FFmpeg reported no usable progress before the playlist appeared: fall back to the two-way // split #472 explicitly accepts, rather than inventing a boundary that was never observed return new ColdStartStartupSplit( prep, Elapsed(processLaunched, playlistExists), TimeSpan.Zero, ColdStartStartupSplitKind.TwoWay); } if (firstProgress > playlistExists) { return new ColdStartStartupSplit( prep, Elapsed(processLaunched, playlistExists), TimeSpan.Zero, ColdStartStartupSplitKind.TwoWayLateProgress); } return new ColdStartStartupSplit( prep, Elapsed(processLaunched, firstProgress), Elapsed(firstProgress, playlistExists), ColdStartStartupSplitKind.ThreeWay); } private static TimeSpan Elapsed(long from, long to) { TimeSpan elapsed = Stopwatch.GetElapsedTime(from, to); return elapsed < TimeSpan.Zero ? TimeSpan.Zero : elapsed; } }