/*
* This file is part of the ZoneMinder Project. See AUTHORS file for Copyright information
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see .
*/
#include "zm_catch2.h"
#include "zm_comms.h"
#include "zm_stream_socket.h"
#include "zm_stream_socket_protocol.h"
#include
#include
#include
#include
#include
#include
#include
using namespace zm::stream_socket;
namespace {
constexpr char kSockPath[] = "/tmp/zm.stream_socket.unittest.sock";
struct AVCodecParametersDeleter {
void operator()(AVCodecParameters *par) const { avcodec_parameters_free(&par); }
};
using codec_parameters_ptr = std::unique_ptr;
struct ReceivedMessage {
Header header;
std::vector payload;
};
// Blocking exact-size reads against the non-blocking-server socket
class TestClient {
public:
bool Connect() {
return sock_.connect(kSockPath);
}
// Returns false on timeout/close instead of blocking forever
bool ReadMessage(ReceivedMessage &message,
std::chrono::milliseconds timeout = std::chrono::seconds(5)) {
uint8_t header_bytes[kHeaderSize];
if (!ReadExact(header_bytes, kHeaderSize, timeout))
return false;
if (!ParseHeader(header_bytes, message.header))
return false;
message.payload.resize(message.header.payload_size());
if (message.payload.empty())
return true;
return ReadExact(message.payload.data(), message.payload.size(), timeout);
}
zm::TcpUnixClient sock_;
private:
bool ReadExact(uint8_t *out, size_t len, std::chrono::milliseconds timeout) {
timeval tv = {};
tv.tv_sec = timeout.count() / 1000;
tv.tv_usec = (timeout.count() % 1000) * 1000;
setsockopt(sock_.getDesc(), SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
size_t done = 0;
while (done < len) {
ssize_t bytes = ::recv(sock_.getDesc(), out + done, len - done, 0);
if (bytes <= 0)
return false;
done += bytes;
}
return true;
}
};
av_packet_ptr make_packet(size_t size, uint8_t fill) {
av_packet_ptr packet{av_packet_alloc()};
REQUIRE(av_new_packet(packet.get(), size) == 0);
memset(packet->data, fill, size);
return packet;
}
codec_parameters_ptr make_h264_parameters() {
codec_parameters_ptr par{avcodec_parameters_alloc()};
par->codec_type = AVMEDIA_TYPE_VIDEO;
par->codec_id = AV_CODEC_ID_H264;
par->width = 640;
par->height = 480;
return par;
}
} // namespace
TEST_CASE("StreamSocket lifecycle", "[stream_socket]") {
StreamSocket server(1, kSockPath);
REQUIRE_FALSE(server.IsRunning());
REQUIRE(server.Start());
REQUIRE(server.IsRunning());
SECTION("socket file exists with 0660 permissions") {
struct stat st = {};
REQUIRE(stat(kSockPath, &st) == 0);
REQUIRE(S_ISSOCK(st.st_mode));
REQUIRE((st.st_mode & 0777) == 0660);
}
SECTION("client can connect") {
TestClient client;
REQUIRE(client.Connect());
}
server.Stop();
REQUIRE_FALSE(server.IsRunning());
// socket file removed on Stop
struct stat st = {};
REQUIRE(stat(kSockPath, &st) != 0);
}
TEST_CASE("StreamSocket sends HELLO first, then media", "[stream_socket]") {
StreamSocket server(1, kSockPath);
REQUIRE(server.Start());
codec_parameters_ptr par = make_h264_parameters();
server.SetVideoParams(par.get(), {30, 1});
TestClient client;
REQUIRE(client.Connect());
ReceivedMessage hello;
REQUIRE(client.ReadMessage(hello));
REQUIRE(hello.header.type == static_cast(MessageType::Hello));
REQUIRE(hello.header.stream == static_cast(StreamId::Video));
HelloInfo info;
REQUIRE(ParseHello(hello.payload.data(), hello.payload.size(), info));
REQUIRE(info.codec_id == AV_CODEC_ID_H264);
REQUIRE(info.width == 640);
REQUIRE(info.height == 480);
REQUIRE(info.fps_num == 30);
// wait for the server to register the client before sending
std::this_thread::sleep_for(std::chrono::milliseconds(50));
av_packet_ptr packet = make_packet(1000, 0xAB);
server.SendMedia(packet.get(), StreamId::Video, true, 123456);
ReceivedMessage media;
REQUIRE(client.ReadMessage(media));
REQUIRE(media.header.type == static_cast(MessageType::Media));
REQUIRE(media.header.flags == kFlagKeyframe);
REQUIRE(media.header.pts_us == 123456);
REQUIRE(media.payload.size() == 1000);
REQUIRE(media.payload[0] == 0xAB);
REQUIRE(media.payload[999] == 0xAB);
server.Stop();
}
TEST_CASE("StreamSocket late joiner receives cached keyframe", "[stream_socket]") {
StreamSocket server(1, kSockPath);
REQUIRE(server.Start());
codec_parameters_ptr par = make_h264_parameters();
server.SetVideoParams(par.get(), {0, 0});
av_packet_ptr keyframe = make_packet(2000, 0x5A);
server.SendMedia(keyframe.get(), StreamId::Video, true, 1000);
av_packet_ptr delta = make_packet(100, 0x11);
server.SendMedia(delta.get(), StreamId::Video, false, 2000);
TestClient client;
REQUIRE(client.Connect());
ReceivedMessage hello;
REQUIRE(client.ReadMessage(hello));
REQUIRE(hello.header.type == static_cast(MessageType::Hello));
ReceivedMessage cached;
REQUIRE(client.ReadMessage(cached));
REQUIRE(cached.header.type == static_cast(MessageType::Keyframe));
REQUIRE(cached.header.pts_us == 1000);
REQUIRE(cached.payload.size() == 2000);
REQUIRE(cached.payload[0] == 0x5A);
server.Stop();
}
TEST_CASE("StreamSocket queue overflow drops media but never HELLO", "[stream_socket]") {
StreamSocket::Config config;
config.queue_max_bytes = 64 * 1024;
config.queue_max_msgs = 16;
config.stats_interval = std::chrono::milliseconds(200);
config.stall_timeout = std::chrono::seconds(60); // not under test here
StreamSocket server(1, kSockPath, config);
REQUIRE(server.Start());
codec_parameters_ptr par = make_h264_parameters();
server.SetVideoParams(par.get(), {0, 0});
TestClient client;
REQUIRE(client.Connect());
std::this_thread::sleep_for(std::chrono::milliseconds(100));
// Push much more than the queue + kernel buffers can hold while the client
// is not reading.
constexpr size_t kPacketSize = 16 * 1024;
constexpr size_t kPackets = 200;
av_packet_ptr packet = make_packet(kPacketSize, 0x42);
for (size_t i = 0; i < kPackets; ++i) {
server.SendMedia(packet.get(), StreamId::Video, false, i);
}
// Now drain everything; track sequence gaps and stats
uint64_t received_media = 0;
uint64_t reported_dropped = 0;
bool got_stats_with_drops = false;
uint32_t last_sequence = 0;
bool first_media = true;
uint64_t gap_total = 0;
// The server emits STATS continuously, so bound the drain by a streak of
// non-media messages rather than a read timeout.
int non_media_streak = 0;
ReceivedMessage message;
while (non_media_streak < 5
and client.ReadMessage(message, std::chrono::milliseconds(1000))) {
++non_media_streak;
if (message.header.type == static_cast(MessageType::Hello))
continue;
if (message.header.type == static_cast(MessageType::Stats)) {
uint64_t sent = 0;
REQUIRE(ParseStats(message.payload.data(), message.payload.size(), sent, reported_dropped));
if (reported_dropped > 0)
got_stats_with_drops = true;
continue;
}
if (message.header.type == static_cast(MessageType::Media)) {
non_media_streak = 0;
if (!first_media) {
REQUIRE(message.header.sequence > last_sequence); // monotonic
gap_total += message.header.sequence - last_sequence - 1;
} else {
gap_total += message.header.sequence; // drops before the first received
first_media = false;
}
last_sequence = message.header.sequence;
++received_media;
}
}
// The client never saw some packets, and the loss is observable both ways
REQUIRE(received_media < kPackets);
REQUIRE(gap_total > 0);
REQUIRE(got_stats_with_drops);
REQUIRE(gap_total + received_media == kPackets);
server.Stop();
}
TEST_CASE("StreamSocket stalled client does not affect a live one", "[stream_socket]") {
StreamSocket::Config config;
config.queue_max_bytes = 32 * 1024;
config.queue_max_msgs = 8;
StreamSocket server(1, kSockPath, config);
REQUIRE(server.Start());
codec_parameters_ptr par = make_h264_parameters();
server.SetVideoParams(par.get(), {0, 0});
TestClient stalled;
REQUIRE(stalled.Connect());
TestClient live;
REQUIRE(live.Connect());
std::this_thread::sleep_for(std::chrono::milliseconds(100));
ReceivedMessage message;
REQUIRE(live.ReadMessage(message)); // HELLO
REQUIRE(message.header.type == static_cast(MessageType::Hello));
constexpr size_t kPackets = 50;
av_packet_ptr packet = make_packet(8 * 1024, 0x99);
uint64_t live_received = 0;
bool live_gap = false;
uint32_t expected_sequence = 0;
for (size_t i = 0; i < kPackets; ++i) {
server.SendMedia(packet.get(), StreamId::Video, false, i);
// live client reads continuously; stalled never reads
if (live.ReadMessage(message)) {
if (message.header.type == static_cast(MessageType::Media)) {
if (message.header.sequence != expected_sequence)
live_gap = true;
expected_sequence = message.header.sequence + 1;
++live_received;
}
}
}
REQUIRE(live_received == kPackets);
REQUIRE_FALSE(live_gap);
server.Stop();
}
TEST_CASE("StreamSocket parameter change bumps generation and resends HELLO", "[stream_socket]") {
StreamSocket server(1, kSockPath);
REQUIRE(server.Start());
codec_parameters_ptr par = make_h264_parameters();
server.SetVideoParams(par.get(), {0, 0});
TestClient client;
REQUIRE(client.Connect());
ReceivedMessage hello;
REQUIRE(client.ReadMessage(hello));
REQUIRE(hello.header.generation == 0);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
// Same parameters: no new HELLO, no generation bump
server.SetVideoParams(par.get(), {0, 0});
// Changed parameters: generation bump + new HELLO
par->width = 1920;
par->height = 1080;
server.SetVideoParams(par.get(), {0, 0});
ReceivedMessage hello2;
REQUIRE(client.ReadMessage(hello2));
REQUIRE(hello2.header.type == static_cast(MessageType::Hello));
REQUIRE(hello2.header.generation == 1);
HelloInfo info;
REQUIRE(ParseHello(hello2.payload.data(), hello2.payload.size(), info));
REQUIRE(info.width == 1920);
// Media now carries the new generation, sequence restarted
std::this_thread::sleep_for(std::chrono::milliseconds(50));
av_packet_ptr packet = make_packet(100, 0x77);
server.SendMedia(packet.get(), StreamId::Video, false, 5000);
ReceivedMessage media;
REQUIRE(client.ReadMessage(media));
REQUIRE(media.header.type == static_cast(MessageType::Media));
REQUIRE(media.header.generation == 1);
REQUIRE(media.header.sequence == 0);
server.Stop();
}
TEST_CASE("StreamSocket sends BYE on stop", "[stream_socket]") {
StreamSocket server(1, kSockPath);
REQUIRE(server.Start());
TestClient client;
REQUIRE(client.Connect());
std::this_thread::sleep_for(std::chrono::milliseconds(100));
server.Stop();
ReceivedMessage message;
REQUIRE(client.ReadMessage(message));
REQUIRE(message.header.type == static_cast(MessageType::Bye));
}
namespace {
std::vector make_state_event(uint16_t code, uint32_t state_id,
uint32_t prev_state_id, const char *name) {
MonitorEvent ev;
ev.code = code;
ev.state_id = state_id; ev.has_state_id = true;
ev.prev_state_id = prev_state_id; ev.has_prev_state_id = true;
ev.state_name = name;
ev.wall_clock_us = 1718355103501000ULL;
ev.has_wall_clock = true;
return BuildEvent(ev);
}
} // namespace
TEST_CASE("StreamSocket broadcasts a monitor EVENT", "[stream_socket]") {
StreamSocket server(1, kSockPath);
REQUIRE(server.Start());
TestClient client;
REQUIRE(client.Connect());
std::this_thread::sleep_for(std::chrono::milliseconds(50));
server.SendMonitorEvent(make_state_event(kEventStateChanged, 2, 0, "Alarm"));
ReceivedMessage message;
REQUIRE(client.ReadMessage(message));
REQUIRE(message.header.type == static_cast(MessageType::Event));
REQUIRE(message.header.stream == static_cast(StreamId::Monitor));
REQUIRE(message.header.sequence == 0);
MonitorEvent ev;
REQUIRE(ParseEvent(message.payload.data(), message.payload.size(), ev));
REQUIRE(ev.code == kEventStateChanged);
REQUIRE(ev.state_id == 2);
REQUIRE(ev.prev_state_id == 0);
REQUIRE(ev.state_name == "Alarm");
REQUIRE(ev.has_wall_clock);
server.Stop();
}
TEST_CASE("StreamSocket event sequence advances without clients", "[stream_socket]") {
StreamSocket server(1, kSockPath);
REQUIRE(server.Start());
// Two events produced before any consumer connects; the sequence still
// advances so the gap is observable to a later joiner.
server.SendMonitorEvent(make_state_event(kEventConnectionFailed, 0, 0, ""));
server.SendMonitorEvent(make_state_event(kEventConnectionRestored, 0, 0, ""));
TestClient client;
REQUIRE(client.Connect());
std::this_thread::sleep_for(std::chrono::milliseconds(50));
server.SendMonitorEvent(make_state_event(kEventStateChanged, 1, 0, "PreAlarm"));
ReceivedMessage message;
REQUIRE(client.ReadMessage(message));
REQUIRE(message.header.type == static_cast(MessageType::Event));
REQUIRE(message.header.sequence == 2); // 0 and 1 dropped before connect
server.Stop();
}
TEST_CASE("StreamSocket replays snapshot on connect", "[stream_socket]") {
StreamSocket server(1, kSockPath);
REQUIRE(server.Start());
codec_parameters_ptr par = make_h264_parameters();
server.SetVideoParams(par.get(), {30, 1});
server.SetSnapshotEvent(make_state_event(kEventSnapshot, 0, 0, "Idle"));
TestClient client;
REQUIRE(client.Connect());
// HELLO first
ReceivedMessage hello;
REQUIRE(client.ReadMessage(hello));
REQUIRE(hello.header.type == static_cast(MessageType::Hello));
// then the snapshot EVENT
ReceivedMessage snap;
REQUIRE(client.ReadMessage(snap));
REQUIRE(snap.header.type == static_cast(MessageType::Event));
REQUIRE(snap.header.stream == static_cast(StreamId::Monitor));
MonitorEvent ev;
REQUIRE(ParseEvent(snap.payload.data(), snap.payload.size(), ev));
REQUIRE(ev.code == kEventSnapshot);
REQUIRE(ev.state_id == 0);
REQUIRE(ev.state_name == "Idle");
server.Stop();
}
TEST_CASE("StreamSocket::ParseAllowedUids", "[stream_socket]") {
REQUIRE(StreamSocket::ParseAllowedUids("").empty());
REQUIRE(StreamSocket::ParseAllowedUids("33") == std::vector{33});
REQUIRE(StreamSocket::ParseAllowedUids("33,1000") == std::vector{33, 1000});
REQUIRE(StreamSocket::ParseAllowedUids(" 33 , 1000 ") == std::vector{33, 1000});
REQUIRE(StreamSocket::ParseAllowedUids("33,,1000") == std::vector{33, 1000});
REQUIRE(StreamSocket::ParseAllowedUids("33,bogus,1000") == std::vector{33, 1000});
}
TEST_CASE("StreamSocket::InvalidateKeyframe stops replaying a stale keyframe", "[stream_socket]") {
StreamSocket server(1, kSockPath);
REQUIRE(server.Start());
codec_parameters_ptr par = make_h264_parameters();
server.SetVideoParams(par.get(), {0, 0});
av_packet_ptr keyframe = make_packet(500, 0x5A);
server.SendMedia(keyframe.get(), StreamId::Video, true, 1000);
// The capture source closes: the cached keyframe belongs to the old session
server.InvalidateKeyframe();
TestClient client;
REQUIRE(client.Connect());
std::this_thread::sleep_for(std::chrono::milliseconds(50));
ReceivedMessage hello;
REQUIRE(client.ReadMessage(hello));
REQUIRE(hello.header.type == static_cast(MessageType::Hello));
// Nothing else is queued for the new consumer until fresh media arrives
av_packet_ptr fresh = make_packet(100, 0x11);
server.SendMedia(fresh.get(), StreamId::Video, false, 2000);
ReceivedMessage next;
REQUIRE(client.ReadMessage(next));
REQUIRE(next.header.type == static_cast(MessageType::Media));
REQUIRE(next.header.pts_us == 2000);
server.Stop();
}