Exercise hung capture stop and destruction without blocking publisher GUI

This commit is contained in:
evgeny committed 2026-11-01 20:10:31 +01:00
1 parent 4ab0be1882
commit b34825cda0
3 files changed
+159 -2

No files matched your search

+1 -1
View File
@@ -98,7 +98,7 @@ StreamPublishEngine::StreamPublishEngine(QObject* parent) : QObject(parent), m(s
m_timer.setTimerType(Qt::PreciseTimer); m_timer.setInterval(100);
connect(&m_timer, &QTimer::timeout, this, &StreamPublishEngine::tick); m_timer.start();
#ifdef HAVE_STREAM_CAPTURE
m->capture = std::make_unique<VideoCapture>(nullptr, VideoCapture::Usage::Stream);
m->capture = std::make_unique<VideoCapture>(this, VideoCapture::Usage::Stream);
connect(m->capture.get(), &VideoCapture::devicesChanged, this, [this](const VideoCaptureDevices& devices) {
m->sources.clear();
for (const auto& device : devices) {
@@ -0,0 +1,59 @@
/* Источник только для тестовых worker-процессов: неподвижный экран и зависания на границах native lifecycle. */
#pragma once
#include "../src/video_capture_source.h"
#include "../../../src/call/call_video_playout.h"
#include <QTimer>
#include <QThread>
#include <QVideoFrameFormat>
#include <cstring>
#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0)
#include <QAbstractVideoBuffer>
class BlockedStreamFrame : public QAbstractVideoBuffer {
public:
MapData map(QVideoFrame::MapMode) override { QThread::sleep(60); return {}; }
QVideoFrameFormat format() const override { return {QSize(16, 16), QVideoFrameFormat::Format_BGRX8888}; }
};
#endif
class StreamCaptureFixture : public VideoCaptureSource {
QTimer timer;
QByteArray mode;
bool started = false;
public:
explicit StreamCaptureFixture(QByteArray testMode) : mode(std::move(testMode)) {
timer.setSingleShot(true);
connect(&timer, &QTimer::timeout, this, [this] {
#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0)
if (mode == "convert") {
QVideoFrame raw(std::make_unique<BlockedStreamFrame>()); raw.setStartTime(call_video_playout_now_us());
emit frame(raw); return;
}
#endif
if (mode == "bad-frame") {
QVideoFrame raw(QVideoFrameFormat(QSize(16, 16), QVideoFrameFormat::Format_Jpeg));
if (raw.map(QVideoFrame::WriteOnly)) { std::memset(raw.bits(0), 0, raw.mappedBytes(0)); raw.unmap(); }
emit frame(raw); return;
}
QImage image(640, 360, QImage::Format_RGB32); image.fill(Qt::red);
#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0)
QVideoFrame raw(image);
#else
QVideoFrame raw(QVideoFrameFormat(image.size(), QVideoFrameFormat::Format_BGRA8888));
if (!raw.map(QVideoFrame::WriteOnly)) return;
for (int row = 0; row < image.height(); ++row)
std::memcpy(raw.bits(0) + row * raw.bytesPerLine(0), image.constScanLine(row), size_t(image.width()) * 4);
raw.unmap();
#endif
raw.setStartTime(call_video_playout_now_us()); emit frame(raw);
});
}
~StreamCaptureFixture() override { if (started && mode == "destroy") QThread::sleep(60); }
VideoCaptureDevices devices(bool) override {
return {{QStringLiteral("Isolated test screen"), "fixture", {}, VideoCaptureKind::Screen, QSize(640, 360)}};
}
bool start(const VideoCaptureConfig&) override { started = true; timer.start(1); return true; }
void stop() override {
timer.stop(); emit stage(QStringLiteral("screen.stop"));
if (started && mode == "stop") QThread::sleep(60);
}
};
+99 -1
View File
@@ -53,6 +53,7 @@ extern "C" {
#include <QVideoWidget>
#include <QVideoSink>
#include <QVideoFrame>
#include "stream_capture_fixture.h"
#endif
#ifdef UTUN_STREAM_X11_CURSOR
#include "../src/stream_screen_cursor.h"
@@ -909,6 +910,94 @@ private slots:
QCOMPARE(status.total_bytes, status.video_bytes + status.audio_bytes + status.stream_bytes + status.transport_bytes + status.inflight_bytes);
gui_stream_publish_stop(id); QTRY_VERIFY_WITH_TIMEOUT(ended(id), 4000);
}
void isolatedCaptureStop_data() {
QTest::addColumn<QByteArray>("mode"); QTest::addColumn<bool>("destroyEngine");
QTest::newRow("screen-stop") << QByteArray("stop") << false;
QTest::newRow("native-destructor") << QByteArray("destroy") << false;
QTest::newRow("engine-destruction") << QByteArray("stop") << true;
#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0)
QTest::newRow("conversion") << QByteArray("convert") << false;
#endif
}
void isolatedCaptureStop() {
#ifdef HAVE_STREAM_CAPTURE
QFETCH(QByteArray, mode); QFETCH(bool, destroyEngine);
qputenv("UTUN_STREAM_CAPTURE_FIXTURE", mode);
auto restore = qScopeGuard([] { qunsetenv("UTUN_STREAM_CAPTURE_FIXTURE"); });
auto engine = std::make_unique<StreamPublishEngine>();
QTRY_VERIFY_WITH_TIMEOUT(!engine->sources().isEmpty(), 4000);
StreamPublishConfig config; config.audio = false; config.showCursor = false;
config.source = engine->sources().front(); config.video.width = 320; config.video.height = 180; config.video.fps = 15;
connect(engine.get(), &StreamPublishEngine::softwareEncodingRequested, engine.get(),
[&](uint64_t generation) { engine->answerSoftwareEncoding(generation, true); });
uint64_t id = 0;
connect(engine.get(), &StreamPublishEngine::telemetryChanged, this, [&](const stream_publisher_status& status) { id = status.id; });
QSignalSpy errors(engine.get(), &StreamPublishEngine::errorOccurred);
QVERIFY(engine->start(group, config)); QTRY_VERIFY_WITH_TIMEOUT(id, 3000);
if (mode != "convert") QTRY_VERIFY_WITH_TIMEOUT(engine->previewFrame().isValid(), 3000);
else QTest::qWait(200); // Worker уже вошёл в заведомо блокирующий map.
unsigned ticks = 0; qint64 previous = 0, gap = 0; QElapsedTimer elapsed; elapsed.start(); QTimer heartbeat;
connect(&heartbeat, &QTimer::timeout, this, [&] {
const qint64 now = elapsed.elapsed(); gap = std::max(gap, now - previous); previous = now; ++ticks;
}); heartbeat.start(10);
QPointer<VideoCapture> capture = engine->findChild<VideoCapture*>(); QVERIFY(capture);
if (destroyEngine) { engine.reset(); QVERIFY(elapsed.elapsed() < 500); }
else {
QSignalSpy stopped(capture, &VideoCapture::stopped);
engine->stop(); QVERIFY(elapsed.elapsed() < 100); QVERIFY(capture->closing());
QTRY_VERIFY_WITH_TIMEOUT(([&] {
stream_publisher_status status{};
return gui_stream_publish_status(id, status) && status.state == STREAM_PUBLISHER_DRAINING;
})(), 1000);
QVERIFY(capture->closing()); // FIN уже отправлен, аппаратное закрытие ещё висит.
QTRY_COMPARE_WITH_TIMEOUT(stopped.count(), 1, 3000);
QVERIFY(!capture->closing()); QTRY_VERIFY_WITH_TIMEOUT(!engine->active(), 4000);
QVERIFY(errors.isEmpty());
// Тот же engine открывает новую сессию после подтверждённого выхода прежнего worker.
qputenv("UTUN_STREAM_CAPTURE_FIXTURE", "idle");
QVERIFY(engine->start(group, config)); QTRY_VERIFY_WITH_TIMEOUT(engine->previewFrame().isValid(), 3000);
engine->stop(); QTRY_VERIFY_WITH_TIMEOUT(!engine->active(), 4000);
}
QTRY_VERIFY_WITH_TIMEOUT(ended(id) || (engine && engine->active()), 4000);
QVERIFY(ticks > 50); QVERIFY2(gap < 200, qPrintable(QString::number(gap)));
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "[PASS] isolated publisher stop mode=%s destroy=%d GUI_gap_ms=%lld ticks=%u",
mode.constData(), destroyEngine, (long long)gap, ticks);
#else
QSKIP("Capture unavailable");
#endif
}
void isolatedStaticScreen() {
#ifdef HAVE_STREAM_CAPTURE
qputenv("UTUN_STREAM_CAPTURE_FIXTURE", "idle");
auto restore = qScopeGuard([] { qunsetenv("UTUN_STREAM_CAPTURE_FIXTURE"); });
StreamPublishEngine engine; QTRY_VERIFY_WITH_TIMEOUT(!engine.sources().isEmpty(), 4000);
StreamPublishConfig config; config.audio = false; config.showCursor = false;
config.source = engine.sources().front(); config.video.width = 320; config.video.height = 180; config.video.fps = 15;
connect(&engine, &StreamPublishEngine::softwareEncodingRequested, &engine,
[&](uint64_t generation) { engine.answerSoftwareEncoding(generation, true); });
QSignalSpy errors(&engine, &StreamPublishEngine::errorOccurred);
QVERIFY(engine.start(group, config)); QTRY_VERIFY_WITH_TIMEOUT(engine.previewFrame().isValid(), 3000);
QTest::qWait(5600); QVERIFY(engine.running()); QVERIFY(errors.isEmpty());
engine.stop(); QTRY_VERIFY_WITH_TIMEOUT(!engine.active(), 4000);
#else
QSKIP("Capture unavailable");
#endif
}
void captureConversionFailure() {
#ifdef HAVE_STREAM_CAPTURE
qputenv("UTUN_STREAM_CAPTURE_FIXTURE", "bad-frame");
auto restore = qScopeGuard([] { qunsetenv("UTUN_STREAM_CAPTURE_FIXTURE"); });
StreamPublishEngine engine; QTRY_VERIFY_WITH_TIMEOUT(!engine.sources().isEmpty(), 4000);
StreamPublishConfig config; config.audio = false; config.showCursor = false; config.source = engine.sources().front();
config.video.width = 320; config.video.height = 180;
QSignalSpy errors(&engine, &StreamPublishEngine::errorOccurred);
QVERIFY(engine.start(group, config)); QTRY_VERIFY_WITH_TIMEOUT(!errors.isEmpty(), 3000);
QVERIFY(errors.front().front().toString().contains("frame conversion failed"));
QTRY_VERIFY_WITH_TIMEOUT(!engine.active(), 4000);
#else
QSKIP("Capture unavailable");
#endif
}
void stopWithGuiThreadCleanup_data() {
QTest::addColumn<bool>("destroy");
QTest::newRow("stop") << false;
@@ -1251,7 +1340,16 @@ private slots:
}
};
int main(int argc, char** argv) {
if (argc >= 2 && QByteArray(argv[1]) == "--video-capture-worker") return video_capture_worker_main(argc, argv);
if (argc >= 2 && QByteArray(argv[1]) == "--video-capture-worker") {
#ifdef HAVE_STREAM_CAPTURE
const auto fixture = qgetenv("UTUN_STREAM_CAPTURE_FIXTURE");
if (!fixture.isEmpty()) {
QGuiApplication app(argc, argv); debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO);
return video_capture_worker_run(QString::fromLocal8Bit(argv[2]), std::make_unique<StreamCaptureFixture>(fixture), true);
}
#endif
return video_capture_worker_main(argc, argv);
}
QApplication app(argc, argv); StreamPublishTest test; return QTest::qExec(&test, argc, argv);
}
#include "test_stream_publish.moc"