Files
XC_VM/tests/Unit/FanoutConfigTest.php
T
obscuremindandClaude Opus 5 8e24a2427a feat(fanout): make encoder supervision a real, settable option
Production-readiness pass on the panel side of daemon encoder supervision.

Until now the feature was gated on a setting that did not exist as a
column, so it could only be enabled by hand-editing the database and the
daemon never learned about it at all.

* Migration 018 adds `fanout_supervise`, off for every existing install.
* Admin -> Settings gains the toggle, alongside the other fanout tuning.
* SettingsService saves it (it is a checkbox, so it has to be in the
  boolean list or it can never be turned back off).
* FanoutConfig writes it into the daemon's config file as `supervise`,
  which is how the node learns it may supervise at all. Both halves must
  be on for anything to change, and either one off is a full rollback.
* StreamProcess reads `fanout_supervise` rather than the placeholder
  name, matching the panel's `fanout_*` convention.

Also: the recorded command file now says WHO ran the stream. `_.fanout`
when the daemon owns the process (the bare command it was handed) and
`_.ffmpeg` when this node ran it itself (with the redirect-and-background
tail). Two names rather than one because the first question in any
incident is which path the stream took, and a single filename cannot
answer it; the stale one is removed so a stream that switched paths does
not leave a lie behind.

FanoutConfigTest covers the new key both ways, including that a settings
array predating it reads as off -- that absence is the upgrade path for
every existing install.

Verified: php -l clean on every changed file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 21:34:50 +01:00

179 lines
6.1 KiB
PHP

<?php
use XcVm\Streaming\Fanout\FanoutConfig;
use PHPUnit\Framework\TestCase;
/**
* FanoutConfig writes the xc_fanout daemon's config.json from panel settings.
* BIN_PATH is defined by tests/bootstrap.php; the daemon tree is created under it.
*/
final class FanoutConfigTest extends TestCase {
private string $dir;
private string $path;
protected function setUp(): void {
$this->dir = rtrim(BIN_PATH, '/') . '/xc_fanout';
$this->path = $this->dir . '/config.json';
if (!is_dir($this->dir)) {
mkdir($this->dir, 0775, true);
}
@unlink($this->path);
}
protected function tearDown(): void {
@unlink($this->path);
@rmdir($this->dir);
}
/** @return array<string,mixed> */
private function read(): array {
$this->assertFileExists($this->path);
$rDecoded = json_decode((string) file_get_contents($this->path), true);
$this->assertIsArray($rDecoded);
return $rDecoded;
}
private function baseSettings(): array {
return array(
'seg_time' => 6,
'client_prebuffer' => 30,
'restreamer_prebuffer' => 0,
'fanout_hls_window' => 6,
'fanout_grace_sec' => 10,
'fanout_write_timeout_sec' => 15,
'fanout_chunk_bytes' => 12032,
'fanout_max_gop_bytes' => 10528000,
'fanout_source_insecure' => 1,
'fanout_default_prebuffer_sec' => 0,
'fanout_idle_buffer_grace_sec' => 30,
'fanout_idle_buffer_ratio' => 0.5,
'fanout_source_backend' => 'auto',
);
}
public function testMapsSettingsToDaemonKeys(): void {
$this->assertTrue(FanoutConfig::sync($this->baseSettings()));
$c = $this->read();
$this->assertSame(6, $c['hls_target_sec']); // ← seg_time
$this->assertSame(6, $c['hls_window']);
$this->assertSame(10, $c['grace_sec']);
$this->assertSame(15, $c['write_timeout_sec']);
$this->assertSame(12032, $c['chunk_bytes']);
$this->assertSame(10528000, $c['max_gop_bytes']);
$this->assertTrue($c['source_insecure']);
$this->assertSame(0, $c['default_prebuffer_sec']);
$this->assertSame(30, $c['idle_buffer_grace_sec']);
$this->assertSame(0.5, $c['idle_buffer_ratio']);
}
/**
* Encoder supervision is off unless the panel says otherwise, and a panel
* that predates the setting must read as off rather than as anything else --
* that absence is the upgrade path for every existing install.
*/
public function testSuperviseDefaultsOff(): void {
$rSettings = $this->baseSettings();
unset($rSettings['fanout_supervise']);
$this->assertTrue(FanoutConfig::sync($rSettings));
$this->assertFalse($this->read()['supervise']);
$rSettings['fanout_supervise'] = 0;
$this->assertTrue(FanoutConfig::sync($rSettings));
$this->assertFalse($this->read()['supervise']);
}
/**
* And it reaches the daemon's config file when it IS set, since that file is
* the only way the node learns it may supervise at all.
*/
public function testSuperviseReachesTheDaemonConfig(): void {
$rSettings = $this->baseSettings();
$rSettings['fanout_supervise'] = 1;
$this->assertTrue(FanoutConfig::sync($rSettings));
$this->assertTrue($this->read()['supervise']);
}
public function testPrebufferMaxSecIsDerivedAndFloored(): void {
// client 30, restreamer 0, hls_window*seg = 6*6 = 36 → floor 40.
$this->assertTrue(FanoutConfig::sync($this->baseSettings()));
$this->assertSame(40, $this->read()['prebuffer_max_sec']);
// A larger HLS window pushes the ring up (12*6 = 72).
$s = $this->baseSettings();
$s['fanout_hls_window'] = 12;
FanoutConfig::sync($s);
$this->assertSame(72, $this->read()['prebuffer_max_sec']);
}
public function testClampsOutOfRangeValues(): void {
$s = $this->baseSettings();
$s['fanout_hls_window'] = 999; // >20
$s['fanout_grace_sec'] = 0; // <1
$s['fanout_write_timeout_sec'] = 9999; // >600
$s['fanout_chunk_bytes'] = 1; // <188
$s['fanout_idle_buffer_ratio'] = 5.0; // >1
$s['seg_time'] = 99; // >30
FanoutConfig::sync($s);
$c = $this->read();
$this->assertSame(20, $c['hls_window']);
$this->assertSame(1, $c['grace_sec']);
$this->assertSame(600, $c['write_timeout_sec']);
$this->assertSame(188, $c['chunk_bytes']);
// A whole-number ratio JSON-encodes as "1", decoding to int — value-compare.
$this->assertEquals(1, $c['idle_buffer_ratio']);
$this->assertSame(30, $c['hls_target_sec']);
}
public function testSourceInsecureFalse(): void {
$s = $this->baseSettings();
$s['fanout_source_insecure'] = 0;
FanoutConfig::sync($s);
$this->assertFalse($this->read()['source_insecure']);
}
public function testIsIdempotent(): void {
$this->assertTrue(FanoutConfig::sync($this->baseSettings()));
// Nothing changed → no rewrite.
$this->assertFalse(FanoutConfig::sync($this->baseSettings()));
}
public function testPreservesUnknownDaemonKeys(): void {
// A key the panel does not own must survive the read-modify-write.
file_put_contents($this->path, json_encode(array('some_future_daemon_key' => 'keep-me')));
FanoutConfig::sync($this->baseSettings());
$c = $this->read();
$this->assertSame('keep-me', $c['some_future_daemon_key']);
$this->assertSame(6, $c['hls_window']);
}
public function testMapsSourceBackend(): void {
$this->assertTrue(FanoutConfig::sync($this->baseSettings()));
$this->assertSame('auto', $this->read()['source_backend']);
}
public function testSourceBackendAcceptsTheDaemonsValues(): void {
foreach (array('auto', 'ffmpeg', 'native') as $rBackend) {
$s = $this->baseSettings();
$s['fanout_source_backend'] = $rBackend;
FanoutConfig::sync($s);
$this->assertSame($rBackend, $this->read()['source_backend']);
}
}
/**
* A typo must never pin a channel to a backend that does not exist: it falls
* back to auto, the same thing the daemon does with an unknown value.
*/
public function testSourceBackendRejectsUnknownValues(): void {
foreach (array('FFMPEG', 'ffmpg', '', 'nativ') as $rBogus) {
$s = $this->baseSettings();
$s['fanout_source_backend'] = $rBogus;
FanoutConfig::sync($s);
$this->assertSame('auto', $this->read()['source_backend'], "bogus value: {$rBogus}");
}
}
}