Files
XC_VM/tests/Unit/IngestFeederTest.php
T
obscuremindandClaude Opus 5 4eea8a587e feat(fanout): IngestFeeder, a reliable daemon feed for the PHP producers
Since ADR 0003 Phase E the xc_fanout daemon is the only client delivery
path, so for the PHP producers (the LLOD segmenter, the loopback relay and,
next, the delay worker) the feed into its ingest socket IS the channel's
delivery. They wrote it with a bare non-blocking fwrite(): a short write
silently dropped the rest of the buffer, tearing TS packets, and after one
failure the feed stopped for the life of the process.

IngestFeeder keeps what the socket did not take and sends it first next
time (capped, shedding the oldest whole packets), discards a half-sent
packet's tail on reconnect so the new connection starts aligned,
re-registers and redials after a daemon restart with a backoff, and
carries the stream's HLS key/iv for encrypted HLS.

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

189 lines
6.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
use PHPUnit\Framework\TestCase;
use XcVm\Streaming\Fanout\IngestFeeder;
/**
* @covers \XcVm\Streaming\Fanout\IngestFeeder
*
* The PHP producers' (LLOD, loopback, delay) feed into the xc_fanout daemon —
* since Phase E the channel's only delivery path. A socket pair stands in for the
* daemon's ingest socket; registration and dialing are injected.
*/
final class IngestFeederTest extends TestCase {
private const P = 188;
/** @var resource[] */
private array $open = [];
protected function tearDown(): void {
foreach ($this->open as $rSocket) {
if (is_resource($rSocket)) {
@fclose($rSocket);
}
}
$this->open = [];
}
/** A connected socket pair: [the feeder's end, the "daemon's" end]. */
private function pair(): array {
$rPair = stream_socket_pair(PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
$this->assertIsArray($rPair, 'socket pair');
$this->open = array_merge($this->open, $rPair);
return $rPair;
}
/** $n TS packets whose bodies number them, so order and loss are visible. */
private function packets(int $n, int $from = 0): string {
$rOut = '';
for ($i = $from; $i < $from + $n; $i++) {
$rOut .= "\x47" . str_pad(pack('N', $i), self::P - 1, "\xff");
}
return $rOut;
}
/**
* A feeder whose "daemon" is the given socket on the first dial (a fresh pair
* on any redial, as a restarted daemon would give); counts registrations.
*/
private function feeder($rSocket, int &$rRegistrations, ?string $rKey = null, ?string $rIV = null, array &$rSeen = []): IngestFeeder {
$rFirst = true;
return new IngestFeeder(
7,
$rKey,
$rIV,
null,
function (int $rID, ?string $rK, ?string $rV) use (&$rRegistrations, &$rSeen) {
$rRegistrations++;
$rSeen = [$rID, $rK, $rV];
return '/run/ingest/7.sock';
},
function (string $rPath) use ($rSocket, &$rFirst) {
if ($rFirst) {
$rFirst = false;
return $rSocket;
}
return $this->pair()[0];
}
);
}
private function drain($rPeer, int $rBytes, IngestFeeder $rFeeder): string {
stream_set_blocking($rPeer, false);
$rGot = '';
$rDeadline = microtime(true) + 10;
while (strlen($rGot) < $rBytes && microtime(true) < $rDeadline) {
$rFeeder->flush();
$rChunk = fread($rPeer, 65536);
if ($rChunk !== false && $rChunk !== '') {
$rGot .= $rChunk;
} else {
usleep(1000);
}
}
return $rGot;
}
public function testRegistersWithTheStreamKeyAndDeliversEveryPacket(): void {
[$rMine, $rDaemon] = $this->pair();
$rRegs = 0;
$rSeen = [];
$rFeeder = $this->feeder($rMine, $rRegs, 'aa', 'bb', $rSeen);
$this->assertTrue($rFeeder->connect());
$this->assertSame([7, 'aa', 'bb'], $rSeen, 'the HLS key travels with the registration');
$rData = $this->packets(50);
$rFeeder->write($rData);
$this->assertSame($rData, $this->drain($rDaemon, strlen($rData), $rFeeder));
$this->assertSame(1, $rRegs);
}
/** A full socket must not lose or tear data: the rest waits and goes next. */
public function testBackpressureKeepsEveryByteInOrder(): void {
[$rMine, $rDaemon] = $this->pair();
$rRegs = 0;
$rFeeder = $this->feeder($rMine, $rRegs);
$rFeeder->connect();
$rAll = '';
for ($i = 0; $i < 40; $i++) { // ~3 MB — far more than a socket buffer holds
$rChunk = $this->packets(400, $i * 400);
$rAll .= $rChunk;
$rFeeder->write($rChunk);
}
$this->assertGreaterThan(0, $rFeeder->backlog(), 'the socket filled, so some of it waited');
$rGot = $this->drain($rDaemon, strlen($rAll), $rFeeder);
$this->assertSame(strlen($rAll), strlen($rGot));
$this->assertTrue($rGot === $rAll, 'identical bytes, in order');
$this->assertSame(0, $rFeeder->backlog());
}
/** After a failure the half-sent packet's tail is discarded, so the next connection starts aligned. */
public function testReconnectStartsOnAPacketBoundary(): void {
[$rMine, $rDaemon] = $this->pair();
$rRegs = 0;
$rFeeder = $this->feeder($rMine, $rRegs);
$rFeeder->connect();
$rRef = new ReflectionClass($rFeeder);
$rRef->getProperty('pending')->setValue($rFeeder, substr($this->packets(3), 100)); // head packet 100 bytes in
$rRef->getProperty('headSent')->setValue($rFeeder, 100);
$rDrop = $rRef->getMethod('drop');
$rDrop->invoke($rFeeder, 'test');
$this->assertFalse($rFeeder->isConnected());
$this->assertSame(2 * self::P, $rFeeder->backlog(), 'only whole packets remain');
$this->assertSame("\x47", substr((string) $rRef->getProperty('pending')->getValue($rFeeder), 0, 1));
// Within the backoff nothing is redialled; after it, the ingest is registered again.
$rFeeder->flush();
$this->assertSame(1, $rRegs);
$rRef->getProperty('retryAt')->setValue($rFeeder, 0.0);
$rFeeder->flush();
$this->assertSame(2, $rRegs, 're-registered (a restarted daemon hands out a fresh socket)');
$this->assertTrue($rFeeder->isConnected());
$this->assertSame(0, $rFeeder->backlog(), 'the aligned remainder went to the new connection');
}
/** With the daemon away the backlog is capped by shedding the OLDEST whole packets. */
public function testOverflowShedsTheOldestWholePackets(): void {
$rFeeder = new IngestFeeder(7, null, null, null, fn() => null, fn() => false);
$rTotal = 0;
$rLast = '';
for ($i = 0; $i < 50; $i++) { // 50 × ~188 KB ≈ 9.4 MB > the 8 MB ceiling
$rLast = $this->packets(1000, $i * 1000);
$rTotal += strlen($rLast);
$rFeeder->write($rLast);
}
$this->assertLessThanOrEqual(8388608, $rFeeder->backlog());
$this->assertSame(0, $rFeeder->backlog() % self::P, 'whole packets only');
$this->assertSame($rTotal - $rFeeder->backlog(), $rFeeder->droppedPackets() * self::P);
$rRef = new ReflectionClass($rFeeder);
$rPending = (string) $rRef->getProperty('pending')->getValue($rFeeder);
$this->assertSame($rLast, substr($rPending, -strlen($rLast)), 'the newest data is what is kept');
}
public function testStreamKeyReadsTheLaunchersKeyFiles(): void {
if (!defined('STREAMS_PATH')) {
define('STREAMS_PATH', sys_get_temp_dir() . '/xcvm-feeder-test/');
}
if (!is_dir(STREAMS_PATH)) {
mkdir(STREAMS_PATH, 0777, true);
}
$rID = 990000 + random_int(0, 9999);
file_put_contents(STREAMS_PATH . $rID . '_.key', str_repeat("\x01", 16));
file_put_contents(STREAMS_PATH . $rID . '_.iv', str_repeat("\x02", 16));
try {
$this->assertSame([str_repeat('01', 16), str_repeat('02', 16)], IngestFeeder::streamKey($rID));
$this->assertSame([null, null], IngestFeeder::streamKey($rID + 1), 'no files, no key');
} finally {
@unlink(STREAMS_PATH . $rID . '_.key');
@unlink(STREAMS_PATH . $rID . '_.iv');
}
}
}