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;
}
}