Files
zoneminder/tests/php/test_stream_error_reason.php
T
Isaac ConnorandClaude Opus 5 ae8c7db488 fix: stop orphaning zms when a stream command fails refs #5029
getStreamCmdResponse() responded to every ajax/stream.php failure the same way:
mint a fresh connkey and reload the img src. ajaxError() returns HTTP 200 with
result=Error, so these arrive in jQuery's done() rather than fail(), and all
twelve error paths in stream.php took that branch.

Only one of them means zms is gone. For the rest the process is still running
and streaming, and replacing the connkey makes it unaddressable: CMD_STOP,
CMD_QUIT and mode=single all then go to the new key, so nothing can reach the
old process and only SIGPIPE can stop it, which we know is unreliable. That is
why the reports of lingering zms after switching monitors were unaffected by
changes to what the stop path sends.

The timeout path made this routine rather than rare. On select() expiry
ajaxError is commented out, so the script carries on to socket_recvfrom() on a
now non-blocking socket. That returns false, and false == 0 under switch's loose
comparison, so a merely slow zms was reported as 'No data to read from socket'
and torn down.

stream.php now classifies each failure as no_socket, timeout, transient or
invalid, and sends it as 'reason'. The client restarts the stream only for
no_socket. A missing reason is still treated as fatal, so a php that predates
this keeps the old behaviour.

Before replacing the connkey the client now sends CMD_QUIT to the old one, so
the process we are about to lose track of is asked to exit. That is deliberately
not routed through streamCommand(): it must name its target explicitly, since
this.connKey is about to change, and its response must not feed back into
getStreamCmdResponse(), or a QUIT that also failed would re-enter the error path
and loop.

ajaxError() takes the classification as a third argument, named $reason because
$code is already the HTTP status, and only includes it when set, so the other
131 callers are unaffected.

Tests: tests/js covers the fatal/non-fatal decision including the no-reason
fallback, tests/php pins the classification mapping and the switch(false)
semantics the timeout branch depends on. Both verified to fail when the
behaviour is reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 06:43:11 -04:00

87 lines
3.5 KiB
PHP

<?php
// Regression test for the failure classification in web/ajax/stream.php.
//
// The client restarts a stream only for a 'no_socket' failure, because
// restarting replaces the connkey and leaves any zms still running unreachable.
// A failure classified too harshly therefore orphans a live zms process; one
// classified too leniently leaves a dead stream on screen.
//
// This parses the classification out of stream.php rather than executing it:
// the script talks to unix sockets and a running zms, so it cannot be driven
// directly. What is worth pinning down is the mapping itself.
//
// Run as: php tests/php/test_stream_error_reason.php
$failures = 0;
$passes = 0;
function check($name, $got, $want) {
global $failures, $passes;
if ($got === $want) {
$passes++;
echo " ok $name\n";
} else {
$failures++;
echo " FAIL $name\n";
echo " got: " . var_export($got, true) . "\n";
echo " want: " . var_export($want, true) . "\n";
}
}
$path = __DIR__ . '/../../web/ajax/stream.php';
$src = file_get_contents($path);
if ($src === false) {
echo "Cannot read $path\n";
exit(1);
}
// Every ajaxError() call must carry a classification, otherwise the client
// falls back to treating it as fatal and we are back to orphaning zms.
$calls = preg_match_all('/^\s*ajaxError\(/m', $src, $m);
$classified = preg_match_all('/STREAM_ERR_[A-Z_]+/', $src, $m2);
echo "ajaxError classification\n";
check('every ajaxError call is classified',
$calls > 0 && $classified >= $calls, true);
// The four classes the client distinguishes.
foreach (array('NO_SOCKET' => 'no_socket', 'TIMEOUT' => 'timeout',
'TRANSIENT' => 'transient', 'INVALID' => 'invalid') as $const => $value) {
check("STREAM_ERR_$const is defined as '$value'",
(bool)preg_match("/define\('STREAM_ERR_$const',\s*'$value'\)/", $src), true);
}
// A missing socket, and a send to a socket with no listener, are the only
// cases that mean zms is gone. These are the ones that may restart the stream.
echo "\nfatal classification\n";
check('missing socket file is no_socket',
(bool)preg_match('/does not exist.*?STREAM_ERR_NO_SOCKET/s', $src), true);
check('socket_sendto failure is no_socket',
(bool)preg_match('/socket_sendto\(.*?STREAM_ERR_NO_SOCKET/s', $src), true);
// A timeout must NOT be reported as a generic failure: zms is most likely
// alive, and restarting it is what orphaned the process.
echo "\nnon-fatal classification\n";
check('select timeout is reported as timeout, not transient',
(bool)preg_match('/\$select_timed_out\s*\)\s*\{\s*ajaxError\(.*?STREAM_ERR_TIMEOUT/s', $src), true);
check('socket_create failure is transient',
(bool)preg_match('/socket_create\(\).*?STREAM_ERR_TRANSIENT/s', $src), true);
check('socket_bind failure is transient',
(bool)preg_match('/socket_bind\(.*?STREAM_ERR_TRANSIENT/s', $src), true);
check('bad request is invalid',
(bool)preg_match('/No connkey or no command.*?STREAM_ERR_INVALID/s', $src), true);
// socket_recvfrom() returns false on failure, and false == 0 under switch's
// loose comparison, so a timed-out select lands on `case 0` rather than -1.
// If this ever stopped holding, the timeout branch would silently never run.
echo "\nphp switch semantics the timeout branch relies on\n";
$matched = null;
switch (false) {
case -1: $matched = -1; break;
case 0: $matched = 0; break;
default: $matched = 'default'; break;
}
check('switch(false) matches case 0, not case -1', $matched, 0);
echo "\n$passes passed, $failures failed\n";
exit($failures ? 1 : 0);