2026-09-11 08:42:47 +00:00
<? php
use PHPUnit\Framework\TestCase ;
use XcVm\Domain\Stream\StreamProcess ;
/**
* The pure parts of handing a live stream to the xc_fanout supervisor: the
* native remuxer command (buildNativeLive), which streams may use it
2026-09-11 10:30:07 +00:00
* (nativeRefusal / isNativeSource), the policy and health the PHP monitor
2026-09-11 08:42:47 +00:00
* obeyed (supervisorPolicy / supervisorHealth), and what a supervisor state
* means for streams_servers (supervisedRowUpdate).
*/
final class StreamProcessSupervisionTest 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 );
}
}
}
private static function call ( string $rMethod , ... $rArgs ) {
$m = new ReflectionMethod ( StreamProcess :: class , $rMethod );
$m -> setAccessible ( true );
return $m -> invoke ( null , ... $rArgs );
}
private function native ( array $rOverrides = []) : string {
return self :: call ( 'buildNativeLive' , array_merge ([
'streamID' => 42 ,
'source' => 'http://src.example/live/u/p/9.ts' ,
'arguments' => [],
'segmentSettings' => [ 'seg_time' => 6 , 'seg_list_size' => 8 , 'seg_delete_threshold' => 4 ],
'ingestSock' => '/run/fanout/ingest/42.sock' ,
'settings' => [ 'ffmpeg_warnings' => 0 , 'fanout_source_insecure' => 1 ],
'binary' => '/home/xc_vm/bin/xc_fanout/xc_fanout' ,
], $rOverrides ));
}
// ── buildNativeLive ───────────────────────────────────────────
public function testNativeCommandShape () : void {
$c = $this -> native ();
$this -> assertStringStartsWith ( '/home/xc_vm/bin/xc_fanout/xc_fanout remux ' , $c );
$this -> assertStringContainsString ( "-i 'http://src.example/live/u/p/9.ts'" , $c );
$this -> assertStringContainsString ( "-ingest 'unix:/run/fanout/ingest/42.sock'" , $c );
$this -> assertStringContainsString ( '-hls_time 6 ' , $c );
$this -> assertStringContainsString ( '-hls_init_time 2 ' , $c , "buildLive's fast first segment" );
$this -> assertStringContainsString ( '-hls_list_size 8 ' , $c );
$this -> assertStringContainsString ( '-hls_delete_threshold 4 ' , $c );
$this -> assertStringContainsString ( "-progress '" . STREAMS_PATH . "42_.progress'" , $c );
$this -> assertStringContainsString ( "-hls_segment_filename '" . STREAMS_PATH . "42_%d.ts'" , $c );
$this -> assertStringEndsWith ( "'" . STREAMS_PATH . "42_.m3u8'" , $c , 'the playlist is the one positional argument, last' );
}
/** The daemon launches it: no redirect, background or pid tail may ride along. */
public function testNativeCommandHasNoLaunchTail () : void {
$c = $this -> native ();
$this -> assertStringNotContainsString ( '>' , $c );
$this -> assertStringNotContainsString ( '&' , $c );
$this -> assertStringNotContainsString ( 'echo' , $c );
}
/** The same fetch identity the daemon's own puller uses for the stream. */
public function testNativeCommandCarriesFetchArguments () : void {
$c = $this -> native ([ 'arguments' => [
'user_agent' => [ 'value' => 'VLC/3.0' , 'argument_default_value' => 'Mozilla/5.0' ],
'proxy' => [ 'value' => '10.0.0.1:3128' ],
'cookie' => [ 'value' => 'a=b' ],
'headers' => [ 'value' => "X-A: 1 \r\n X-B: 2 \r\n " ],
]]);
$this -> assertStringContainsString ( "-user_agent 'VLC/3.0'" , $c );
$this -> assertStringContainsString ( "-http_proxy '10.0.0.1:3128'" , $c );
$this -> assertStringContainsString ( '-cookies ' , $c );
$this -> assertStringContainsString ( "-headers 'X-A: 1 \r\n X-B: 2 \r\n '" , $c );
$this -> assertStringContainsString ( "-user_agent 'Mozilla/5.0'" , $this -> native (), 'no UA set: the puller default' );
}
public function testNativeInsecureFollowsTheSetting () : void {
$this -> assertStringContainsString ( ' -insecure ' , $this -> native ());
$this -> assertStringNotContainsString ( '-insecure' , $this -> native ([ 'settings' => [ 'fanout_source_insecure' => 0 ]]));
}
/** A source URL is shell data: quotes in it must not escape the argument. */
public function testNativeSourceIsShellEscaped () : void {
$c = $this -> native ([ 'source' => "http://x/a'; rm -rf /;'.ts" ]);
$this -> assertStringContainsString ( "-i 'http://x/a' \\ ''; rm -rf /;' \\ ''.ts'" , $c );
}
// ── eligibility ───────────────────────────────────────────────
private function plainStream ( array $rOverrides = []) : array {
return array_merge ([
2026-09-11 10:44:23 +00:00
// `streams_types`: (1, 'Live Streams', 'live'), (3, 'created_live'),
// (4, 'radio_streams') — the key, not the table name.
'type_key' => 'live' ,
2026-09-11 08:42:47 +00:00
'enable_transcode' => 0 ,
'custom_ffmpeg' => '' ,
'custom_map' => '' ,
'rtmp_output' => 0 ,
'external_push' => '' ,
'gen_timestamps' => 0 ,
'read_native' => 0 ,
], $rOverrides );
}
public function testPlainCopyStreamIsNativeEligible () : void {
2026-09-11 10:30:07 +00:00
$this -> assertNull ( self :: call ( 'nativeRefusal' , $this -> plainStream (), []));
2026-09-11 08:42:47 +00:00
}
2026-09-11 10:44:23 +00:00
/**
* Both default to 1 in `streams`, so they say nothing about this channel —
* refusing them is refusing every channel, which is how the native backend
* came to never run.
*/
public function testSchemaDefaultsDoNotRefuseTheRemuxer () : void {
$this -> assertNull ( self :: call ( 'nativeRefusal' , $this -> plainStream ([ 'gen_timestamps' => 1 , 'read_native' => 1 ]), []));
}
2026-09-11 08:42:47 +00:00
public function testAnythingNeedingFfmpegIsNotNativeEligible () : void {
foreach ([
'transcode' => [ 'enable_transcode' => 1 ],
'custom ffmpeg' => [ 'custom_ffmpeg' => '-i x -c:v libx264 y' ],
'custom map' => [ 'custom_map' => '-map 0:0' ],
'rtmp output' => [ 'rtmp_output' => 1 ],
'external push here' => [ 'external_push' => json_encode ([ 1 => [ 'rtmp://push/x' ]])],
'radio' => [ 'type_key' => 'radio_streams' ],
'created channel' => [ 'type_key' => 'created_live' ],
2026-09-11 10:44:23 +00:00
'a type key that is not the schema\'s' => [ 'type_key' => 'live_streams' ],
2026-09-11 08:42:47 +00:00
] as $rWhy => $rOverride ) {
2026-09-11 10:30:07 +00:00
// The refusal is written to the stream's log, so every one of them
// must say something an operator can act on.
$this -> assertIsString ( self :: call ( 'nativeRefusal' , $this -> plainStream ( $rOverride ), []), $rWhy );
2026-09-11 08:42:47 +00:00
}
2026-09-11 10:30:07 +00:00
$this -> assertNull ( self :: call ( 'nativeRefusal' , $this -> plainStream ([ 'external_push' => json_encode ([ 7 => [ 'rtmp://push/x' ]])]), []), "another server's push is not this one's" );
$this -> assertIsString ( self :: call ( 'nativeRefusal' , $this -> plainStream (), [ 'force_input_acodec' => [ 'value' => 'ac3' ]]), 'forced input codec' );
}
public function testRemuxCommandIsRecognisedForTheCommandRecord () : void {
$rCmd = $this -> native ([ 'binary' => \XcVm\Streaming\Fanout\FanoutClient :: binaryPath ()]);
$this -> assertTrue ( self :: call ( 'isRemuxCommand' , $rCmd ), 'the remuxer command is recorded as <id>_.fanout' );
$this -> assertFalse ( self :: call ( 'isRemuxCommand' , '/bin/ffmpeg40 -i x -c copy -f tee y' ), 'an ffmpeg command is not' );
2026-09-11 08:42:47 +00:00
}
public function testNativeSources () : void {
foreach ([ 'http://h/x.ts' , 'https://h/x.m3u8' , 'udp://239.0.0.1:1234' , 'rtp://239.0.0.1:5000' ] as $rURL ) {
$this -> assertTrue ( self :: call ( 'isNativeSource' , $rURL ), $rURL );
}
foreach ([ 'rtmp://h/app/s' , 'srt://h:9000' , '/srv/film.mp4' , 'https://www.youtube.com/watch?v=x' ] as $rURL ) {
$this -> assertFalse ( self :: call ( 'isNativeSource' , $rURL ), $rURL );
}
}
// ── policy / health ───────────────────────────────────────────
public function testHealthMirrorsThePhpMonitor () : void {
$h = self :: call ( 'supervisorHealth' , [ 'fps_restart' => 1 , 'fps_threshold' => 90 , 'auto_restart' => json_encode ([ 'days' => [ 'Monday' ], 'at' => '04:30' ])], [ 'seg_time' => 10 , 'audio_restart_loss' => 1 , 'fps_delay' => 60 ]);
$this -> assertSame ( 60 , $h [ 'stall_sec' ], 'seg_time × 6' );
$this -> assertSame ( 30 , $h [ 'audio_loss_sec' ]);
$this -> assertEqualsWithDelta ( 0.9 , $h [ 'fps_threshold' ], 1e-9 , '"FPS Threshold %" is a percentage; the daemon takes a fraction' );
$this -> assertSame ( 60 , $h [ 'fps_grace_sec' ]);
$this -> assertSame ([ 'days' => [ 'Monday' ], 'at' => '04:30' ], $h [ 'auto_restart' ]);
$off = self :: call ( 'supervisorHealth' , [ 'fps_restart' => 0 , 'fps_threshold' => 90 , 'auto_restart' => '' ], [ 'seg_time' => 10 ]);
$this -> assertSame ( 0 , $off [ 'fps_threshold' ], 'fps_restart off' );
$this -> assertSame ( 0 , $off [ 'audio_loss_sec' ]);
$this -> assertArrayNotHasKey ( 'auto_restart' , $off );
}
public function testPolicyMirrorsThePhpMonitor () : void {
$p = self :: call ( 'supervisorPolicy' , [ 'on_demand' => 1 , 'parent_id' => 0 ], [ 'stop_failures' => 3 , 'stream_fail_sleep' => 7 , 'on_demand_failure_exit' => 1 , 'priority_backup' => 1 , 'seg_time' => 10 ], 2 , 5 );
$this -> assertSame ( 3 , $p [ 'stop_failures' ]);
$this -> assertSame ( 7 , $p [ 'stream_fail_sleep' ]);
$this -> assertTrue ( $p [ 'on_demand' ]);
$this -> assertTrue ( $p [ 'on_demand_failure_exit' ]);
$this -> assertSame ( 300 , $p [ 'priority_backup_sec' ]);
$this -> assertSame ( 5 + 30 , $p [ 'start_timeout_sec' ], 'probe window + the playlist wait' );
$one = self :: call ( 'supervisorPolicy' , [ 'on_demand' => 0 , 'parent_id' => 0 ], [ 'priority_backup' => 1 , 'seg_time' => 10 ], 1 , 5 );
$this -> assertSame ( 0 , $one [ 'priority_backup_sec' ], 'nothing to switch back to with one source' );
$loop = self :: call ( 'supervisorPolicy' , [ 'on_demand' => 0 , 'parent_id' => 3 ], [ 'priority_backup' => 1 , 'seg_time' => 10 ], 2 , 5 );
$this -> assertSame ( 0 , $loop [ 'priority_backup_sec' ], 'a loopback has no sources of its own' );
}
// ── reconcile ─────────────────────────────────────────────────
private function row ( array $rOverrides = []) : array {
return array_merge ([
'pid' => null , 'monitor_pid' => 900 , 'stream_status' => 2 , 'current_source' => 'http://a/1.ts' ,
'stream_started' => 1000 , 'stream_info' => null , 'audio_codec' => null , 'video_codec' => null ,
'resolution' => null , 'bitrate' => null , 'compatible' => 0 ,
], $rOverrides );
}
private function update ( array $rRow , array $rState ) : array {
return self :: call ( 'supervisedRowUpdate' , $rRow , array_merge ([ 'daemon_pid' => 900 ], $rState ), false , 2000 );
}
public function testConfirmedStartIsUpAndDated () : void {
$set = $this -> update ( $this -> row (), [ 'running' => true , 'confirmed' => true , 'pid' => 4321 , 'uptime_ms' => 5000 , 'source' => 'http://a/1.ts' ]);
$this -> assertSame ( 0 , $set [ 'stream_status' ]);
$this -> assertSame ( 4321 , $set [ 'pid' ]);
$this -> assertSame ( 1995 , $set [ 'stream_started' ], 'when the running producer came up' );
$this -> assertArrayNotHasKey ( 'current_source' , $set , 'unchanged columns are not rewritten' );
}
public function testStatusFollowsThePhpMonitorsMeaning () : void {
$this -> assertSame ( 2 , $this -> update ( $this -> row ([ 'stream_status' => 0 ]), [ 'running' => true , 'confirmed' => false , 'pid' => 5 ])[ 'stream_status' ], 'launched, not confirmed' );
$this -> assertSame ( 1 , $this -> update ( $this -> row (), [ 'running' => false , 'failures' => 2 ])[ 'stream_status' ], 'between failed starts' );
$this -> assertSame ( 1 , $this -> update ( $this -> row (), [ 'running' => false , 'gave_up' => true ])[ 'stream_status' ], 'gave up' );
$this -> assertArrayNotHasKey ( 'stream_status' , $this -> update ( $this -> row (), [ 'running' => false , 'failures' => 0 ]), 'a first start still pending stays "starting"' );
$this -> assertNull ( $this -> update ( $this -> row ([ 'pid' => 77 , 'stream_status' => 0 ]), [ 'running' => false , 'failures' => 1 ])[ 'pid' ], 'nothing running, no pid' );
}
public function testMetadataFromTheBytes () : void {
2026-09-11 11:03:06 +00:00
$set = $this -> update ( $this -> row (), [ 'running' => true , 'confirmed' => true , 'pid' => 1 , 'meta' => [ 'video_codec' => 'h264' , 'audio_codec' => 'aac' , 'width' => 1920 , 'height' => 1088 , 'bitrate_kbps' => 4500 ]]);
2026-09-11 08:42:47 +00:00
$this -> assertSame ( 'h264' , $set [ 'video_codec' ]);
$this -> assertSame ( 'aac' , $set [ 'audio_codec' ]);
$this -> assertSame ( 1 , $set [ 'compatible' ]);
$this -> assertSame ( 1080 , $set [ 'resolution' ], 'snapped to the nearest standard height' );
$this -> assertSame ( 4500 , $set [ 'bitrate' ]);
}
2026-09-11 11:03:06 +00:00
/**
* The streams list, the adaptive master playlist and the viewer's codec all
* read the `stream_info` JSON, not the flat columns — a supervised stream
* that never runs ffprobe has to have it written from the daemon's reading.
*/
public function testMetadataAlsoFillsTheStreamInfoJson () : void {
$set = $this -> update ( $this -> row (), [ 'running' => true , 'confirmed' => true , 'pid' => 1 , 'meta' => [ 'video_codec' => 'hevc' , 'audio_codec' => 'ac3' , 'width' => 1920 , 'height' => 1080 , 'bitrate_kbps' => 4500 ]]);
$rInfo = json_decode ( $set [ 'stream_info' ], true );
$this -> assertSame ( 'hevc' , $rInfo [ 'codecs' ][ 'video' ][ 'codec_name' ]);
$this -> assertSame ( 'video' , $rInfo [ 'codecs' ][ 'video' ][ 'codec_type' ]);
$this -> assertSame ( 1920 , $rInfo [ 'codecs' ][ 'video' ][ 'width' ]);
$this -> assertSame ( 1080 , $rInfo [ 'codecs' ][ 'video' ][ 'height' ]);
$this -> assertSame ( 'ac3' , $rInfo [ 'codecs' ][ 'audio' ][ 'codec_name' ]);
$this -> assertSame ( 4500000 , $rInfo [ 'bitrate' ], 'bit/s here, kbit/s in the column' );
}
/** Whatever ffprobe once found is kept; only what the daemon read is replaced. */
public function testStreamInfoIsMergedNotReplaced () : void {
$rRow = $this -> row ([ 'stream_info' => json_encode ([
'container' => 'mpegts' ,
'codecs' => [ 'video' => [ 'codec_name' => 'h264' , 'codec_type' => 'video' , 'width' => 1280 , 'height' => 720 , 'r_frame_rate' => 25 ]],
])]);
$set = $this -> update ( $rRow , [ 'running' => true , 'confirmed' => true , 'pid' => 1 , 'meta' => [ 'video_codec' => 'h264' , 'width' => 1920 , 'height' => 1080 ]]);
$rInfo = json_decode ( $set [ 'stream_info' ], true );
$this -> assertSame ( 'mpegts' , $rInfo [ 'container' ]);
$this -> assertSame ( 25 , $rInfo [ 'codecs' ][ 'video' ][ 'r_frame_rate' ], 'a field the daemon does not read survives' );
$this -> assertSame ( 1920 , $rInfo [ 'codecs' ][ 'video' ][ 'width' ]);
$this -> assertSame ( 1080 , $rInfo [ 'codecs' ][ 'video' ][ 'height' ]);
}
/** Nothing new read means no write: the JSON is not rewritten every pass. */
public function testStreamInfoIsNotRewrittenWhenNothingChanged () : void {
$rRow = $this -> row ([ 'pid' => 1 , 'stream_status' => 0 , 'video_codec' => 'h264' , 'audio_codec' => 'aac' , 'stream_info' => json_encode ([
'codecs' => [
'video' => [ 'codec_name' => 'h264' , 'codec_type' => 'video' , 'width' => 1920 , 'height' => 1080 ],
'audio' => [ 'codec_name' => 'aac' , 'codec_type' => 'audio' ],
],
])]);
$set = $this -> update ( $rRow , [ 'running' => true , 'confirmed' => true , 'pid' => 1 , 'meta' => [ 'video_codec' => 'h264' , 'audio_codec' => 'aac' , 'width' => 1920 , 'height' => 1080 ]]);
$this -> assertArrayNotHasKey ( 'stream_info' , $set );
}
2026-09-11 08:42:47 +00:00
/** Unknown is left unknown: a correct value is never overwritten with a blank. */
public function testUnknownMetadataKeepsWhatThePanelHas () : void {
$rRow = $this -> row ([ 'video_codec' => 'hevc' , 'audio_codec' => 'ac3' , 'resolution' => 2160 , 'bitrate' => 9000 , 'stream_status' => 0 , 'pid' => 1 ]);
$set = $this -> update ( $rRow , [ 'running' => true , 'confirmed' => true , 'pid' => 1 , 'meta' => []]);
foreach ([ 'video_codec' , 'audio_codec' , 'resolution' , 'bitrate' , 'compatible' ] as $rCol ) {
$this -> assertArrayNotHasKey ( $rCol , $set , $rCol );
}
$audioOnly = $this -> update ( $rRow , [ 'running' => true , 'confirmed' => true , 'pid' => 1 , 'meta' => [ 'audio_codec' => 'aac' ]]);
$this -> assertArrayNotHasKey ( 'video_codec' , $audioOnly , 'a known video codec is not blanked by an audio-only reading' );
}
public function testNothingChangedWritesNothing () : void {
$rRow = $this -> row ([ 'pid' => 4321 , 'stream_status' => 0 ]);
$this -> assertSame ([], $this -> update ( $rRow , [ 'running' => true , 'confirmed' => true , 'pid' => 4321 , 'source' => 'http://a/1.ts' ]));
}
}