Skip to content

Commit ae8c7db

Browse files
fix: stop orphaning zms when a stream command fails refs ZoneMinder#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>
1 parent 237295b commit ae8c7db

5 files changed

Lines changed: 253 additions & 15 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
'use strict';
2+
3+
const assert = require('assert');
4+
const path = require('path');
5+
const ZM = require(path.join(__dirname, '../../web/js/MonitorStream.js'));
6+
7+
let passed = 0;
8+
let failed = 0;
9+
function test(name, fn) {
10+
try {
11+
fn();
12+
console.log(' ok ' + name);
13+
passed++;
14+
} catch (e) {
15+
console.error(' FAIL ' + name);
16+
console.error(' ' + e.message);
17+
failed++;
18+
}
19+
}
20+
21+
console.log('streamErrorIsFatal');
22+
23+
// Only a missing zms should tear the stream down. Restarting replaces the
24+
// connkey, which makes any zms still running unreachable, so treating a
25+
// recoverable failure as fatal is what orphaned the process.
26+
test('no_socket -> fatal, zms really is gone', () => {
27+
assert.strictEqual(ZM.streamErrorIsFatal('no_socket'), true);
28+
});
29+
30+
test('timeout -> not fatal, zms is most likely alive and busy', () => {
31+
assert.strictEqual(ZM.streamErrorIsFatal('timeout'), false);
32+
});
33+
34+
test('transient -> not fatal, the failure was local to php', () => {
35+
assert.strictEqual(ZM.streamErrorIsFatal('transient'), false);
36+
});
37+
38+
test('invalid -> not fatal, restarting cannot fix a bad request', () => {
39+
assert.strictEqual(ZM.streamErrorIsFatal('invalid'), false);
40+
});
41+
42+
// A php that predates the reason field sends no reason at all. Falling back to
43+
// the old always-restart behaviour keeps a mixed-version install working.
44+
test('missing reason -> fatal, preserves pre-reason behaviour', () => {
45+
assert.strictEqual(ZM.streamErrorIsFatal(undefined), true);
46+
});
47+
48+
test('null reason -> fatal', () => {
49+
assert.strictEqual(ZM.streamErrorIsFatal(null), true);
50+
});
51+
52+
test('empty reason -> fatal', () => {
53+
assert.strictEqual(ZM.streamErrorIsFatal(''), true);
54+
});
55+
56+
// An unknown reason from a newer php should not be silently treated as fatal:
57+
// the conservative choice is to leave the stream alone and retry.
58+
test('unrecognised reason -> not fatal', () => {
59+
assert.strictEqual(ZM.streamErrorIsFatal('something_new'), false);
60+
});
61+
62+
console.log('\n' + passed + ' passed, ' + failed + ' failed');
63+
process.exit(failed ? 1 : 0);
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
// Regression test for the failure classification in web/ajax/stream.php.
3+
//
4+
// The client restarts a stream only for a 'no_socket' failure, because
5+
// restarting replaces the connkey and leaves any zms still running unreachable.
6+
// A failure classified too harshly therefore orphans a live zms process; one
7+
// classified too leniently leaves a dead stream on screen.
8+
//
9+
// This parses the classification out of stream.php rather than executing it:
10+
// the script talks to unix sockets and a running zms, so it cannot be driven
11+
// directly. What is worth pinning down is the mapping itself.
12+
//
13+
// Run as: php tests/php/test_stream_error_reason.php
14+
15+
$failures = 0;
16+
$passes = 0;
17+
18+
function check($name, $got, $want) {
19+
global $failures, $passes;
20+
if ($got === $want) {
21+
$passes++;
22+
echo " ok $name\n";
23+
} else {
24+
$failures++;
25+
echo " FAIL $name\n";
26+
echo " got: " . var_export($got, true) . "\n";
27+
echo " want: " . var_export($want, true) . "\n";
28+
}
29+
}
30+
31+
$path = __DIR__ . '/../../web/ajax/stream.php';
32+
$src = file_get_contents($path);
33+
if ($src === false) {
34+
echo "Cannot read $path\n";
35+
exit(1);
36+
}
37+
38+
// Every ajaxError() call must carry a classification, otherwise the client
39+
// falls back to treating it as fatal and we are back to orphaning zms.
40+
$calls = preg_match_all('/^\s*ajaxError\(/m', $src, $m);
41+
$classified = preg_match_all('/STREAM_ERR_[A-Z_]+/', $src, $m2);
42+
echo "ajaxError classification\n";
43+
check('every ajaxError call is classified',
44+
$calls > 0 && $classified >= $calls, true);
45+
46+
// The four classes the client distinguishes.
47+
foreach (array('NO_SOCKET' => 'no_socket', 'TIMEOUT' => 'timeout',
48+
'TRANSIENT' => 'transient', 'INVALID' => 'invalid') as $const => $value) {
49+
check("STREAM_ERR_$const is defined as '$value'",
50+
(bool)preg_match("/define\('STREAM_ERR_$const',\s*'$value'\)/", $src), true);
51+
}
52+
53+
// A missing socket, and a send to a socket with no listener, are the only
54+
// cases that mean zms is gone. These are the ones that may restart the stream.
55+
echo "\nfatal classification\n";
56+
check('missing socket file is no_socket',
57+
(bool)preg_match('/does not exist.*?STREAM_ERR_NO_SOCKET/s', $src), true);
58+
check('socket_sendto failure is no_socket',
59+
(bool)preg_match('/socket_sendto\(.*?STREAM_ERR_NO_SOCKET/s', $src), true);
60+
61+
// A timeout must NOT be reported as a generic failure: zms is most likely
62+
// alive, and restarting it is what orphaned the process.
63+
echo "\nnon-fatal classification\n";
64+
check('select timeout is reported as timeout, not transient',
65+
(bool)preg_match('/\$select_timed_out\s*\)\s*\{\s*ajaxError\(.*?STREAM_ERR_TIMEOUT/s', $src), true);
66+
check('socket_create failure is transient',
67+
(bool)preg_match('/socket_create\(\).*?STREAM_ERR_TRANSIENT/s', $src), true);
68+
check('socket_bind failure is transient',
69+
(bool)preg_match('/socket_bind\(.*?STREAM_ERR_TRANSIENT/s', $src), true);
70+
check('bad request is invalid',
71+
(bool)preg_match('/No connkey or no command.*?STREAM_ERR_INVALID/s', $src), true);
72+
73+
// socket_recvfrom() returns false on failure, and false == 0 under switch's
74+
// loose comparison, so a timed-out select lands on `case 0` rather than -1.
75+
// If this ever stopped holding, the timeout branch would silently never run.
76+
echo "\nphp switch semantics the timeout branch relies on\n";
77+
$matched = null;
78+
switch (false) {
79+
case -1: $matched = -1; break;
80+
case 0: $matched = 0; break;
81+
default: $matched = 'default'; break;
82+
}
83+
check('switch(false) matches case 0, not case -1', $matched, 0);
84+
85+
echo "\n$passes passed, $failures failed\n";
86+
exit($failures ? 1 : 0);

web/ajax/stream.php

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,26 @@
77
define('MSG_TIMEOUT', ZM_WEB_AJAX_TIMEOUT/2);
88
define('MSG_DATA_SIZE', 4+256);
99

10+
/* Failure classes reported to the client as 'reason'. The client needs to
11+
* tell "zms is gone, start a new one" apart from "this particular exchange
12+
* failed", because restarting the stream replaces the connkey and makes any
13+
* still-running zms unreachable.
14+
*
15+
* no_socket - zms is not listening: it never started, or it has exited.
16+
* The only class where restarting the stream is the right answer.
17+
* timeout - the command went out but no reply came back in time. zms is
18+
* most likely alive and busy.
19+
* transient - a failure local to this php process (socket setup, a short
20+
* read). Says nothing at all about zms.
21+
* invalid - bad request or an unexpected reply. Retrying will not help.
22+
*/
23+
define('STREAM_ERR_NO_SOCKET', 'no_socket');
24+
define('STREAM_ERR_TIMEOUT', 'timeout');
25+
define('STREAM_ERR_TRANSIENT', 'transient');
26+
define('STREAM_ERR_INVALID', 'invalid');
27+
1028
if ( !($_REQUEST['connkey'] && $_REQUEST['command']) ) {
11-
ajaxError('No connkey or no command in stream ajax');
29+
ajaxError('No connkey or no command in stream ajax', HTTP_STATUS_OK, STREAM_ERR_INVALID);
1230
}
1331

1432
@mkdir(ZM_PATH_SOCKS);
@@ -38,13 +56,13 @@
3856

3957
if (!($socket = @socket_create(AF_UNIX, SOCK_DGRAM, 0))) {
4058
if ($semaphore) sem_release($semaphore);
41-
ajaxError('socket_create() failed: '.socket_strerror(socket_last_error()));
59+
ajaxError('socket_create() failed: '.socket_strerror(socket_last_error()), HTTP_STATUS_OK, STREAM_ERR_TRANSIENT);
4260
}
4361

4462
$localSocketFile = ZM_PATH_SOCKS.'/zms-'.$connkey.'w.sock';
4563
if (!socket_bind($socket, $localSocketFile)) {
4664
if ($semaphore) sem_release($semaphore);
47-
ajaxError("socket_bind( $localSocketFile ) failed: ".socket_strerror(socket_last_error()));
65+
ajaxError("socket_bind( $localSocketFile ) failed: ".socket_strerror(socket_last_error()), HTTP_STATUS_OK, STREAM_ERR_TRANSIENT);
4866
}
4967

5068
switch ($_REQUEST['command']) {
@@ -99,51 +117,62 @@
99117

100118
if (!file_exists($remSockFile)) {
101119
if ($semaphore) sem_release($semaphore);
102-
ajaxError("Socket $remSockFile does not exist. This file is created by zms, and since it does not exist, either zms did not run, or zms exited early. Please check your zms logs and ensure that CGI is enabled in apache and check that the PATH_ZMS is set correctly. Make sure that ZM is actually recording. If you are trying to view a live stream and the capture process (zmc) is not running then zms will exit. Please go to http://zoneminder.readthedocs.io/en/latest/faq.html#why-can-t-i-see-streamed-images-when-i-can-see-stills-in-the-zone-window-etc for more information.");
120+
ajaxError("Socket $remSockFile does not exist. This file is created by zms, and since it does not exist, either zms did not run, or zms exited early. Please check your zms logs and ensure that CGI is enabled in apache and check that the PATH_ZMS is set correctly. Make sure that ZM is actually recording. If you are trying to view a live stream and the capture process (zmc) is not running then zms will exit. Please go to http://zoneminder.readthedocs.io/en/latest/faq.html#why-can-t-i-see-streamed-images-when-i-can-see-stills-in-the-zone-window-etc for more information.", HTTP_STATUS_OK, STREAM_ERR_NO_SOCKET);
103121
} else {
104122
if (!@socket_sendto($socket, $msg, strlen($msg), 0, $remSockFile)) {
105123
if ($semaphore) sem_release($semaphore);
106-
ajaxError("socket_sendto( $remSockFile ) failed: ".socket_strerror(socket_last_error()));
124+
ajaxError("socket_sendto( $remSockFile ) failed: ".socket_strerror(socket_last_error()), HTTP_STATUS_OK, STREAM_ERR_NO_SOCKET);
107125
}
108126
}
109127

110128
$rSockets = array($socket);
111129
$wSockets = NULL;
112130
$eSockets = NULL;
131+
$select_timed_out = false;
113132

114133
$timeout = MSG_TIMEOUT - ( time() - $start_time );
115134

116135
$numSockets = socket_select($rSockets, $wSockets, $eSockets, intval($timeout/1000), ($timeout%1000)*1000);
117136

118137
if ( $numSockets === false ) {
119138
if ($semaphore) sem_release($semaphore);
120-
ajaxError('socket_select failed: '.socket_strerror(socket_last_error()));
139+
ajaxError('socket_select failed: '.socket_strerror(socket_last_error()), HTTP_STATUS_OK, STREAM_ERR_TRANSIENT);
121140
} else if ( $numSockets < 0 ) {
122141
if ($semaphore) sem_release($semaphore);
123-
ajaxError("Socket closed $remSockFile");
142+
ajaxError("Socket closed $remSockFile", HTTP_STATUS_OK, STREAM_ERR_NO_SOCKET);
124143
} else if ( $numSockets == 0 ) {
125144
ZM\Error("Timed out waiting for msg $remSockFile after waiting $timeout milliseconds");
126145
socket_set_nonblock($socket);
146+
// Not an error on its own: the socket is now non-blocking, so the recvfrom
147+
// below returns immediately and reports this as a timeout rather than
148+
// pretending zms had nothing to say.
149+
$select_timed_out = true;
127150
#ajaxError("Timed out waiting for msg $remSockFile");
128151
} else if ( $numSockets > 0 ) {
129152
if ( count($rSockets) != 1 ) {
130153
if ($semaphore) sem_release($semaphore);
131-
ajaxError('Bogus return from select, '.count($rSockets).' sockets available');
154+
ajaxError('Bogus return from select, '.count($rSockets).' sockets available', HTTP_STATUS_OK, STREAM_ERR_TRANSIENT);
132155
}
133156
}
134157

135158
$nbytes = @socket_recvfrom($socket, $msg, MSG_DATA_SIZE, 0, $remSockFile);
136159
if ($semaphore) sem_release($semaphore);
137160
switch ($nbytes) {
138161
case -1 :
139-
ajaxError("socket_recvfrom( $remSockFile ) failed: ".socket_strerror(socket_last_error()));
162+
ajaxError("socket_recvfrom( $remSockFile ) failed: ".socket_strerror(socket_last_error()), HTTP_STATUS_OK, STREAM_ERR_TRANSIENT);
140163
break;
141164
case 0 :
142-
ajaxError('No data to read from socket');
165+
// socket_recvfrom() returns false on error, and false == 0 under switch's
166+
// loose comparison, so a timed-out select lands here rather than on -1.
167+
if ($select_timed_out) {
168+
ajaxError("Timed out waiting for msg $remSockFile after waiting $timeout milliseconds",
169+
HTTP_STATUS_OK, STREAM_ERR_TIMEOUT);
170+
}
171+
ajaxError('No data to read from socket', HTTP_STATUS_OK, STREAM_ERR_TRANSIENT);
143172
break;
144173
default :
145174
if ( $nbytes != MSG_DATA_SIZE ) {
146-
ajaxError("Got unexpected message size, got $nbytes, expected ".MSG_DATA_SIZE);
175+
ajaxError("Got unexpected message size, got $nbytes, expected ".MSG_DATA_SIZE, HTTP_STATUS_OK, STREAM_ERR_TRANSIENT);
147176
}
148177
break;
149178
}
@@ -197,9 +226,9 @@
197226
ajaxResponse(array('status'=>$data));
198227
break;
199228
default :
200-
ajaxError('Unexpected received message type '.$data['type']);
229+
ajaxError('Unexpected received message type '.$data['type'], HTTP_STATUS_OK, STREAM_ERR_INVALID);
201230
}
202-
ajaxError('Unrecognised action or insufficient permissions in ajax/stream');
231+
ajaxError('Unrecognised action or insufficient permissions in ajax/stream', HTTP_STATUS_OK, STREAM_ERR_INVALID);
203232

204233
function ajaxCleanup() {
205234
global $socket, $localSocketFile;

web/includes/functions.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1791,13 +1791,20 @@ function jsonDecode($value) {
17911791
define('HTTP_STATUS_BAD_REQUEST', 400);
17921792
define('HTTP_STATUS_FORBIDDEN', 403);
17931793

1794-
function ajaxError($message, $code=HTTP_STATUS_OK) {
1794+
/* $reason is an optional machine-readable classification of the failure, for
1795+
* callers that need to react differently to different errors instead of
1796+
* parsing $message. It is named $reason rather than $code because $code is
1797+
* already taken by the HTTP status. Included in the response only when set,
1798+
* so existing callers and their clients are unaffected.
1799+
*/
1800+
function ajaxError($message, $code=HTTP_STATUS_OK, $reason=null) {
17951801
$backTrace = debug_backtrace();
17961802
ZM\Debug($message.' from '.print_r($backTrace, true));
17971803
if ( function_exists('ajaxCleanup') )
17981804
ajaxCleanup();
17991805
if ( $code == HTTP_STATUS_OK ) {
18001806
$response = array('result'=>'Error', 'message'=>$message);
1807+
if ($reason) $response['reason'] = $reason;
18011808
header('Content-type: application/json');
18021809
exit(jsonEncode($response));
18031810
}

web/js/MonitorStream.js

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@
22
var janus = null;
33
const streaming = [];
44

5+
/* Does this ajax/stream.php failure mean the zms behind our connkey is gone?
6+
*
7+
* Only then is it right to tear the stream down and start a new one, because
8+
* doing so replaces the connkey and leaves any still-running zms unaddressable.
9+
* A slow reply or a socket problem local to php says nothing about zms, and
10+
* restarting on those is what left processes behind.
11+
*
12+
* An absent reason is treated as fatal so that a php that predates the reason
13+
* field keeps the older behaviour.
14+
*/
15+
function streamErrorIsFatal(reason) {
16+
if (!reason) return true;
17+
return reason == 'no_socket';
18+
}
19+
520
function MonitorStream(monitorData) {
621
this.id = monitorData.id;
722
this.name = monitorData.name;
@@ -1389,11 +1404,23 @@ function MonitorStream(monitorData) {
13891404
} else {
13901405
if (!this.started) return;
13911406
console.error(respObj.message);
1407+
1408+
// Only a zms that is actually gone justifies tearing the stream down;
1409+
// see streamErrorIsFatal().
1410+
if (!streamErrorIsFatal(respObj.reason)) {
1411+
console.log('Not reloading stream for '+respObj.reason+' error, will retry on the next poll');
1412+
return;
1413+
}
1414+
13921415
// Try to reload the image stream.
13931416
console.log('Reloading stream: ' + stream.src);
1394-
// Instead of changing rand, perhaps we should be changing connKey.
13951417
let src = (-1 != stream.src.indexOf('rand=')) ? stream.src.replace(/rand=\d+/i, 'rand='+Math.floor((Math.random() * 1000000) )) : stream.src+'&rand='+Math.floor((Math.random() * 1000000));
13961418
src = src.replace(/auth=\w+/i, 'auth='+auth_hash);
1419+
/* Make the old zms exit before we stop being able to address it. Once
1420+
* the connkey is replaced nothing can reach the old process, so if it
1421+
* missed SIGPIPE it would linger and keep streaming forever.
1422+
*/
1423+
this.quitConnKey(this.connKey);
13971424
this.streamCmdParms.connkey = this.statusCmdParms.connkey = this.connKey = this.genConnKey();
13981425
src = src.replace(/connkey=\d+/i, 'connkey='+this.connKey);
13991426
stream.src = '';
@@ -1592,6 +1619,28 @@ function MonitorStream(monitorData) {
15921619
}
15931620
};
15941621

1622+
/* Tell the zms behind a specific connkey to exit.
1623+
*
1624+
* Deliberately not routed through streamCommand()/streamCmdReq():
1625+
* - those send to this.connKey at request time, and the caller here is
1626+
* about to replace it, so the QUIT has to name its target explicitly;
1627+
* - their response is fed back into getStreamCmdResponse(), and this is
1628+
* called from that function's error path. A QUIT that also failed would
1629+
* re-enter the error path, quit again, and loop.
1630+
* The outcome is ignored on purpose: this is best effort, and there is
1631+
* nothing useful to do if the process is already gone.
1632+
*/
1633+
this.quitConnKey = function(connkey) {
1634+
if (!connkey) return;
1635+
const params = Object.assign({}, this.streamCmdParms, {command: CMD_QUIT, connkey: connkey});
1636+
jQuery.ajaxQueue({
1637+
url: this.url + (auth_relay?'?'+auth_relay:''),
1638+
xhrFields: {withCredentials: true},
1639+
data: params,
1640+
dataType: 'json'
1641+
});
1642+
};
1643+
15951644
this.streamCommand = function(command) {
15961645
if (!this.started) {
15971646
console.log('Not sending command, stream not started', command);
@@ -2615,3 +2664,7 @@ function appendMseBuffer(packet, context) {
26152664
context.restart(context.currentChannelStream, 1000);
26162665
}
26172666
}
2667+
2668+
if (typeof module !== 'undefined' && module.exports) {
2669+
module.exports = {streamErrorIsFatal};
2670+
}

0 commit comments

Comments
 (0)