fix(render): keep the whole SDL lifecycle on the render thread
The receiver window never appeared on Wayland. Two causes: - The window was created on the main thread while event pumping and presenting ran on the render thread. SDL's Wayland backend requires a window's creation, event processing, drawing, and destruction to happen on one thread; a cross-thread surface simply never maps, with no error reported. The renderer now lives entirely on the render thread, with a condition-variable handshake so ReceiverPipeline:: start() still reports renderer failures and timeouts. - On Wayland a window is invisible until the first render commit, so even a healthy receiver showed nothing while waiting for a stream. The renderer presents one blank frame at init: the window is visible immediately, black until video arrives. Also log 'stream started (WxH)' when the first frame decodes and the first rendering failure, which is what made the remaining debugging observable. Validated headlessly end-to-end: a synthetic solid-color RTP feed drove the real receiver over localhost UDP; the window mapped, reported 'stream started (320x240)', and a grim screenshot of the window region showed the fed color (V=198, SATAVG=74 during the red feed). Phase 5 marked complete in docs/PHASES.md; current phase is now Phase 6.
This commit is contained in:
@@ -31,6 +31,21 @@ loopback validation pending. See bottom for the run commands.
|
|||||||
of bytes), microsecond encoder time_base (kills the 25fps pts
|
of bytes), microsecond encoder time_base (kills the 25fps pts
|
||||||
quantization that duplicated timestamps), and a best-effort 4MB
|
quantization that duplicated timestamps), and a best-effort 4MB
|
||||||
SO_RCVBUF on the receive socket (kernel clamps to rmem_max).
|
SO_RCVBUF on the receive socket (kernel clamps to rmem_max).
|
||||||
|
- **Display fixes after the receiver window never appeared**: (1) SDL must
|
||||||
|
own the window from one thread — creating it on main and pumping/presenting
|
||||||
|
from the render thread left the Wayland surface unmapped; the renderer now
|
||||||
|
creates/polls/presents/destroys entirely on the render thread (with a
|
||||||
|
start() init handshake). (2) Wayland windows are invisible until the first
|
||||||
|
render commit, so the renderer presents a blank frame at init (visible
|
||||||
|
black window while waiting). (3) The receiver logs `stream started (WxH)`
|
||||||
|
on the first decoded frame and any first present failure.
|
||||||
|
- **Phase 5 validated end-to-end** (headless, without the portal): a
|
||||||
|
synthetic RTP feed of solid red/green frames drove the real receiver over
|
||||||
|
localhost UDP; the window mapped, logged `stream started (320x240)`, and a
|
||||||
|
`grim` screenshot of the window region showed the fed color (V=198,
|
||||||
|
SAT=74 during the red feed) — decoded video visibly rendering. The user's
|
||||||
|
real sender run showed capture→encode→send working (1 IDR + 12 P-frames,
|
||||||
|
IDR capped at ~20KB by the VBV).
|
||||||
- **Automated validation**: `meson test` 4/4 — new `udp loopback` test
|
- **Automated validation**: `meson test` 4/4 — new `udp loopback` test
|
||||||
encodes synthetic frames, packetizes, sends over a real localhost UDP
|
encodes synthetic frames, packetizes, sends over a real localhost UDP
|
||||||
socket, depacketizes, and decodes 10/10 frames with correct dimensions.
|
socket, depacketizes, and decodes 10/10 frames with correct dimensions.
|
||||||
|
|||||||
+5
-5
@@ -54,10 +54,10 @@ H.264 bitstream.
|
|||||||
|
|
||||||
**Goal**: send RTP packets over UDP and render the result locally.
|
**Goal**: send RTP packets over UDP and render the result locally.
|
||||||
|
|
||||||
- Implement `RtpTransport` with ASIO or raw UDP sockets.
|
- [x] Implement `RtpTransport` with ASIO or raw UDP sockets.
|
||||||
- Wire sender pipeline: capture → encode → RTP → localhost UDP.
|
- [x] Wire sender pipeline: capture → encode → RTP → localhost UDP.
|
||||||
- Wire receiver pipeline: localhost UDP → RTP → decode → renderer.
|
- [x] Wire receiver pipeline: localhost UDP → RTP → decode → renderer.
|
||||||
- Add SDL2/3 dependency and a minimal `Renderer`.
|
- [x] Add SDL2/SDL3 dependency and a minimal `Renderer`.
|
||||||
|
|
||||||
**Validation**: `screencast --send` and `screencast --receive` on the same
|
**Validation**: `screencast --send` and `screencast --receive` on the same
|
||||||
machine show the captured desktop in a window.
|
machine show the captured desktop in a window.
|
||||||
@@ -89,4 +89,4 @@ where available.
|
|||||||
|
|
||||||
## Current phase
|
## Current phase
|
||||||
|
|
||||||
Phase 5 — Local UDP Sender → Receiver Loopback.
|
Phase 6 — LAN Signaling and Discovery.
|
||||||
|
|||||||
+55
-10
@@ -3,6 +3,7 @@
|
|||||||
#include "screencast/network/h264_packetizer.h"
|
#include "screencast/network/h264_packetizer.h"
|
||||||
|
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
#include <condition_variable>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
@@ -113,14 +114,6 @@ class ReceiverPipeline::Impl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool start() {
|
bool start() {
|
||||||
auto renderer_result = RendererFactory::create(config_.renderer);
|
|
||||||
if (is_renderer_error(renderer_result)) {
|
|
||||||
std::cerr << std::format("screencast: renderer creation failed: {}\n",
|
|
||||||
renderer_error(renderer_result).message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
renderer_ = std::move(renderer_value(renderer_result));
|
|
||||||
|
|
||||||
auto decoder_result = DecoderFactory::create(config_.decoder);
|
auto decoder_result = DecoderFactory::create(config_.decoder);
|
||||||
if (is_codec_error(decoder_result)) {
|
if (is_codec_error(decoder_result)) {
|
||||||
std::cerr << std::format("screencast: decoder creation failed: {}\n", codec_error(decoder_result).message);
|
std::cerr << std::format("screencast: decoder creation failed: {}\n", codec_error(decoder_result).message);
|
||||||
@@ -134,7 +127,50 @@ class ReceiverPipeline::Impl {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
render_thread_ = std::jthread([this](std::stop_token stop_token) { render_loop(std::move(stop_token)); });
|
// Every SDL call — window creation, event pumping, presenting, and
|
||||||
|
// destruction — happens on the render thread. The Wayland backend
|
||||||
|
// does not tolerate cross-thread windows: created on another thread,
|
||||||
|
// the surface never maps and no window appears.
|
||||||
|
std::mutex init_mutex;
|
||||||
|
std::condition_variable init_done_cv;
|
||||||
|
bool init_done = false;
|
||||||
|
bool init_ok = false;
|
||||||
|
|
||||||
|
render_thread_ =
|
||||||
|
std::jthread([this, &init_mutex, &init_done_cv, &init_done, &init_ok](std::stop_token stop_token) {
|
||||||
|
auto renderer_result = RendererFactory::create(config_.renderer);
|
||||||
|
if (is_renderer_error(renderer_result)) {
|
||||||
|
std::cerr << std::format("screencast: renderer creation failed: {}\n",
|
||||||
|
renderer_error(renderer_result).message);
|
||||||
|
} else {
|
||||||
|
renderer_ = std::move(renderer_value(renderer_result));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
std::lock_guard lock(init_mutex);
|
||||||
|
init_ok = !is_renderer_error(renderer_result);
|
||||||
|
init_done = true;
|
||||||
|
}
|
||||||
|
init_done_cv.notify_all();
|
||||||
|
if (!init_ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
render_loop(std::move(stop_token));
|
||||||
|
if (renderer_ != nullptr) {
|
||||||
|
renderer_->shutdown();
|
||||||
|
renderer_ = nullptr;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
std::unique_lock lock(init_mutex);
|
||||||
|
if (!init_done_cv.wait_for(lock, std::chrono::seconds(10), [&init_done] { return init_done; })) {
|
||||||
|
transport_->stop();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!init_ok) {
|
||||||
|
render_thread_ = std::jthread{};
|
||||||
|
transport_->stop();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +203,10 @@ class ReceiverPipeline::Impl {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (DecodedFrame& decoded : codec_value(decoded_result)) {
|
for (DecodedFrame& decoded : codec_value(decoded_result)) {
|
||||||
|
if (!stream_started_) {
|
||||||
|
stream_started_ = true;
|
||||||
|
std::cerr << std::format("screencast: stream started ({}x{})\n", decoded.width, decoded.height);
|
||||||
|
}
|
||||||
std::lock_guard lock(queue_mutex_);
|
std::lock_guard lock(queue_mutex_);
|
||||||
// Keep latency low: drop the oldest frame when the queue is full.
|
// Keep latency low: drop the oldest frame when the queue is full.
|
||||||
if (queue_.size() >= kMaxQueuedFrames) {
|
if (queue_.size() >= kMaxQueuedFrames) {
|
||||||
@@ -190,7 +230,10 @@ class ReceiverPipeline::Impl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (frame.has_value()) {
|
if (frame.has_value()) {
|
||||||
(void)renderer_->present(*frame);
|
if (!renderer_->present(*frame) && !present_error_logged_) {
|
||||||
|
present_error_logged_ = true;
|
||||||
|
std::cerr << "screencast: rendering a decoded frame failed\n";
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
}
|
}
|
||||||
@@ -207,6 +250,8 @@ class ReceiverPipeline::Impl {
|
|||||||
std::jthread render_thread_;
|
std::jthread render_thread_;
|
||||||
std::mutex queue_mutex_;
|
std::mutex queue_mutex_;
|
||||||
std::deque<DecodedFrame> queue_;
|
std::deque<DecodedFrame> queue_;
|
||||||
|
bool stream_started_ = false;
|
||||||
|
bool present_error_logged_ = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
ReceiverPipeline::ReceiverPipeline(ReceiverPipelineConfig config) : impl_(std::make_unique<Impl>(std::move(config))) {}
|
ReceiverPipeline::ReceiverPipeline(ReceiverPipelineConfig config) : impl_(std::make_unique<Impl>(std::move(config))) {}
|
||||||
|
|||||||
@@ -47,6 +47,17 @@ class SdlRenderer final : public Renderer {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Commit the surface once: on Wayland a window only becomes visible
|
||||||
|
// after the first present, and the receiver must be visible while it
|
||||||
|
// waits for the stream to start.
|
||||||
|
SDL_SetRenderDrawColor(renderer_, 0, 0, 0, 255);
|
||||||
|
if (!SDL_RenderClear(renderer_)) {
|
||||||
|
last_error_ = std::string{"SDL_RenderClear failed: "} + SDL_GetError();
|
||||||
|
shutdown();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
SDL_RenderPresent(renderer_);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user