Files
XC_VM/tests/Unit/StreamProcessBuildLiveTest.php
T
obscuremindandClaude Opus 5 2b61e48ef5 fix(streaming): feed delayed, LLOD and loopback channels to the daemon
Client delivery is daemon-only since ADR 0003 Phase E, but several
producers never fed the daemon, or fed it in a way no player could use:

- Delayed channels (delay_minutes > 0) were unwatchable: the encoder's tee
  is skipped for them (its output is the undelayed stream), the supervisor
  refuses them, and DelayCommand never registered an ingest, so every
  viewer got not-on-air. DelayCommand now pushes each delayed segment into
  the daemon as it publishes it (IngestFeeder), paced over the segment's
  duration, with a two-segment burst at start. Its loop polls every 50 ms
  instead of hashing the playlist every 1 ms.
- An ffmpeg loopback (php_loopback off, or no supervision) registered no
  ingest at all; startStream now tees loopback streams too.
- Encrypted HLS was undecodable for loopback and llod=2: the playlist
  declares AES-128 whenever encrypt_hls is on, but those producers
  registered without the key, so the daemon served plain segments. Every
  producer now passes the key; startLLOD/startLoopback write it before
  spawning the child that registers.
- LlodCommand/LoopbackCommand feed through IngestFeeder (no torn packets,
  reconnect after a daemon restart). LLOD also honours the stream's
  headers/cookie/proxy/default user agent and request_prebuffer, accepts
  TS sources by content when the Content-Type is not video/mp2t, kills a
  stale segmenter by process title (it read the MONITOR's pid), and writes
  its playlist atomically (the loopback relay too).
- LLOD ffmpeg: +nobuffer moves to the input where it has an effect;
  -tune zerolatency only for x264/x265 (NVENC rejects it and failed the
  start; it gets -zerolatency 1); an LLOD start uses the first source
  instead of falling through to the last.
- Remote VOD subtitles URL-encoded the shell-quoted path.
- cron:streams: the restreamer "attached" subqueries lacked GROUP BY, so
  on-demand parents could be stopped under a child LB; a stopped on-demand
  stream went on to spawn thumbnail/archive workers; the daemon re-feed
  restart now skips self-feeding producers (PHP relays, delay) and its
  throttle stamp moved out of STREAMS_PATH, where the restart's
  `rm -f <id>_*` deleted it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:19:02 +01:00

210 lines
9.4 KiB
PHP

<?php
use PHPUnit\Framework\TestCase;
use XcVm\Domain\Stream\StreamProcess;
/**
* Characterisation tests for StreamProcess::buildLive() — the pure live ffmpeg
* command assembler extracted from startStream. These lock the string structure
* per branch (simple / custom_ffmpeg / loopback / delay / rtmp) and, crucially,
* assert no unresolved {TOKEN} survives — the most likely extraction defect.
*
* The authoritative equivalence oracle is the shadow-diff wired into startStream
* (both strings computed on real traffic, divergence logged); these fixtures
* guard against future drift.
*/
final class StreamProcessBuildLiveTest extends TestCase {
public static function setUpBeforeClass(): void {
foreach ([
'SERVER_ID' => 1,
'STREAMS_PATH' => '/tmp/xcvm-test-streams/',
'DELAY_PATH' => '/tmp/xcvm-test-delay/',
'FFMPEG_BIN_40' => '/bin/ffmpeg40',
'FFPROBE_BIN_40' => '/bin/ffprobe40',
] as $k => $v) {
if (!defined($k)) {
define($k, $v);
}
}
}
/** Base $data for a plain live restream; override per test. */
private function data(array $overrides = []): array {
$base = [
'stream' => [
'stream_info' => [
'custom_ffmpeg' => '',
'stream_all' => 0,
'custom_map' => '',
'type_key' => 'live',
'gen_timestamps' => 0,
'read_native' => 0,
'enable_transcode' => 0,
'transcode_profile_id' => 0,
'transcode_attributes' => '[]',
'profile_options' => '[]',
'delay_minutes' => 0,
'rtmp_output' => 0,
],
'server_info' => ['parent_id' => 0, 'server_id' => 1],
'stream_arguments' => [],
],
'settings' => [
'ffmpeg_warnings' => 0,
'read_native_hls' => 0,
'dts_legacy_ffmpeg' => 0,
'ignore_keyframes' => 0,
'live_streaming_pass' => 'secret',
],
'servers' => [1 => ['rtmp_port' => 1935]],
'streamID' => 42,
'streamSource' => 'http://src.example/live.ts',
'fetchOptions' => '',
'ffprobe' => ['container' => 'mpegts', 'codecs' => ['video' => ['codec_name' => 'h264'], 'audio' => ['codec_name' => 'aac']]],
'protocol' => 'http',
'source' => 'http://src.example/live.ts',
'segmentSettings' => ['seg_time' => 6, 'seg_list_size' => 8, 'seg_delete_threshold' => 4],
'externalPush' => [],
'probesize' => 1000000,
'analyseDuration' => 500000,
'llod' => false,
'loopback' => false,
'segmentStart' => 0,
'delayActive' => false,
'ffmpegCpu' => '/bin/ffmpeg',
'ffmpegGpu' => '/bin/ffmpeg-gpu',
];
// shallow-merge stream_info / settings so tests can tweak single keys
if (isset($overrides['stream_info'])) {
$base['stream']['stream_info'] = array_merge($base['stream']['stream_info'], $overrides['stream_info']);
unset($overrides['stream_info']);
}
if (isset($overrides['server_info'])) {
$base['stream']['server_info'] = array_merge($base['stream']['server_info'], $overrides['server_info']);
unset($overrides['server_info']);
}
return array_merge($base, $overrides);
}
private function build(array $overrides = []): string {
$m = new ReflectionMethod(StreamProcess::class, 'buildLive');
$m->setAccessible(true);
return $m->invoke(null, $this->data($overrides));
}
// ── the key invariant: every placeholder is resolved ───────
public function testNoUnresolvedPlaceholderRemains(): void {
foreach ([
[], // simple
['loopback' => true], // loopback
['delayActive' => true, 'segmentStart' => 3, 'stream_info' => ['delay_minutes' => 5]],
['stream_info' => ['rtmp_output' => 1]], // rtmp
['stream_info' => ['custom_ffmpeg' => '-i in -c copy out']],
] as $ov) {
$out = $this->build($ov);
$this->assertStringNotContainsString('{', $out, 'unresolved placeholder in: ' . $out);
}
}
// ── simple live ────────────────────────────────────────────
public function testSimpleLiveStructure(): void {
$out = $this->build();
$this->assertStringStartsWith('/bin/ffmpeg ', $out, 'cpu bin (no gpu)');
$this->assertStringContainsString('-progress "' . STREAMS_PATH . '42_.progress"', $out);
$this->assertStringContainsString("-i 'http://src.example/live.ts'", $out);
$this->assertStringContainsString(STREAMS_PATH . '42_%d.ts', $out, 'hls segments');
$this->assertStringContainsString('>/dev/null 2>>' . STREAMS_PATH . '42.errors', $out);
$this->assertStringContainsString('echo $! > ' . STREAMS_PATH . '42_.pid', $out);
}
// ── supervised (launched by the fanout daemon) ─────────────
/** The daemon is the parent: the shell's redirect/background/pid tail must go. */
public function testSupervisedOmitsTheLaunchTail(): void {
$out = $this->build(['supervised' => true]);
$this->assertStringNotContainsString('>/dev/null', $out);
$this->assertStringNotContainsString('echo $!', $out);
$this->assertStringNotContainsString(' & ', $out);
$this->assertStringContainsString(STREAMS_PATH . '42_.m3u8', $out, 'still names its playlist (adopt_match)');
}
/** A supervised loopback feeds the daemon, which confirms and judges it by those bytes. */
public function testSupervisedLoopbackTeesIntoTheDaemon(): void {
$out = $this->build(['loopback' => true, 'ingestSock' => '/run/ingest/42.sock', 'supervised' => true]);
$this->assertStringContainsString('-f tee', $out);
$this->assertStringContainsString('unix:/run/ingest/42.sock', $out);
$this->assertStringContainsString('-map 0 -copy_unknown', $out);
}
/** With no daemon ingest (daemon unreachable) a loopback stays on-disk only. */
public function testLoopbackWithoutIngestStaysOnDiskOnly(): void {
$this->assertStringNotContainsString('-f tee', $this->build(['loopback' => true]));
}
/** A self-launched (unsupervised) loopback feeds the daemon too — clients are daemon-only. */
public function testUnsupervisedLoopbackTeesIntoTheDaemon(): void {
$out = $this->build(['loopback' => true, 'ingestSock' => '/run/ingest/42.sock']);
$this->assertStringContainsString('-f tee', $out);
$this->assertStringContainsString('unix:/run/ingest/42.sock', $out);
}
// ── LLOD ───────────────────────────────────────────────────
/** +nobuffer is a demuxer flag: it belongs before -i, not among the output options. */
public function testLlodLatencyFlagsSitOnTheInput(): void {
$out = $this->build(['llod' => true]);
$input = substr($out, 0, (int) strpos($out, ' -i '));
$this->assertStringContainsString('-fflags +discardcorrupt+nobuffer', $input);
$this->assertStringNotContainsString('-fflags nobuffer', $out);
}
/** A stream copy gets no encoder tune; x264 gets zerolatency; NVENC its own option. */
public function testLlodTuneMatchesTheEncoder(): void {
$this->assertStringNotContainsString('-tune', $this->build(['llod' => true]));
$x264 = $this->build(['llod' => true, 'stream_info' => ['enable_transcode' => 1, 'transcode_profile_id' => 1, 'profile_options' => json_encode(['-vcodec' => 'libx264'])]]);
$this->assertStringContainsString('-tune zerolatency', $x264);
$nvenc = $this->build(['llod' => true, 'stream_info' => ['enable_transcode' => 1, 'transcode_profile_id' => 1, 'profile_options' => json_encode(['-vcodec' => 'h264_nvenc'])]]);
$this->assertStringNotContainsString('-tune zerolatency', $nvenc, 'NVENC rejects the x264 tune value');
$this->assertStringContainsString('-zerolatency 1', $nvenc);
}
// ── custom_ffmpeg branch ───────────────────────────────────
public function testCustomFfmpegBypassesTemplate(): void {
$out = $this->build(['stream_info' => ['custom_ffmpeg' => '-i INPUT -c:v libx264 OUTPUT']]);
$this->assertStringContainsString('-i INPUT -c:v libx264 OUTPUT', $out);
// custom path emits neither gen_pts input flags nor a fetch-options slot
$this->assertStringNotContainsString('-thread_queue_size', $out);
}
// ── loopback ───────────────────────────────────────────────
public function testLoopbackHasNoReconnectAndNoLlod(): void {
$out = $this->build(['loopback' => true, 'llod' => true]);
$this->assertStringNotContainsString('-reconnect', $out, 'loopback source is not http-reconnect');
$this->assertStringNotContainsString('-tune zerolatency', $out, 'LLOD options suppressed on loopback');
}
// ── delay resume ───────────────────────────────────────────
public function testDelayUsesDelayPathAndStartNumber(): void {
$out = $this->build(['delayActive' => true, 'segmentStart' => 3, 'stream_info' => ['delay_minutes' => 5]]);
$this->assertStringContainsString('-start_number 3', $out);
$this->assertStringContainsString(DELAY_PATH . '42_%d.ts', $out);
$this->assertStringContainsString('-f hls', $out);
}
// ── rtmp output ────────────────────────────────────────────
public function testRtmpOutputAppendsFlvTarget(): void {
$out = $this->build(['stream_info' => ['rtmp_output' => 1]]);
$this->assertStringContainsString('rtmp://127.0.0.1:1935/live/42?password=secret', $out);
$this->assertStringContainsString('-f flv', $out);
}
}