The receiver service never started on the Pi: systemd blocks before exec in acquire_terminal(), waiting for a controlling terminal that the console session or getty on tty1 already owns. Symptoms: the status shows the main PID as "(screencast)" with Tasks:1 and ~40ms CPU, a silent journal, and no mDNS announcement — the binary never ran. Reproduced locally with a transient unit (the desktop's Wayland session owns tty1) and diagnosed by gdb-attaching the stuck process. The service now owns a dedicated free VT (tty7) so acquisition is immediate, with a tolerant ExecStartPre chvt to make the screencast the on-screen console at boot. tty1 keeps the console login; Ctrl+Alt+F1/Ctrl+Alt+F7 switch between them. The previous advice to enable Console Autologin actively caused the hang and is removed from the unit, RUNBOOK, and install script notes. Validated locally: the same properties via systemd-run give a running receiver with all ~15 threads alive that announces itself and is found by --discover; verified unit passes systemd-analyze verify; graceful stop withdraws mDNS.
11 KiB
Project Memory — screen_cast
Last updated: Phase 6 (discovery + signaling) complete and validated over loopback; current phase is Phase 7.
Project state
-
Phase 6 done: Avahi mDNS discovery (
_screencast._tcp— receiver announces its signaling port via a threaded-poll Avahi client; senders browse+resolve) plus JSON session signaling (offer/answer) over TCP with a signaling server on the receiver (port 5005).--sendauto-discovers when exactly one receiver is found;--discoverlists receivers;--peertargets a receiver directly.meson test5/5, valgrind clean. -
Signaling is newline-delimited JSON over TCP, not WebSocket: no WS library was installed and the rtp-networking skill permits plain TCP. The wire format (one JSON object per line: offer/answer with session id, codec, rtp port) is transport-agnostic; Phase 7 adds the WS dependency if needed.
-
Dual-stack everywhere: this machine resolves its own services over IPv6 (ULA + link-local), so the UDP transport, signaling client, and both listeners now support both families (IPv6 sockets with IPV6_V6ONLY=0 for dual-stack listening; AF_UNSPEC getaddrinfo for peers). Validated over both 127.0.0.1 and ::1.
-
Discovery dedupe: one entry per (service_name, signaling_port) — a host with many interfaces otherwise registers dozens of address variants.
-
Receiver-only builds (
-Dsender=false, meson option): skip the capture backend and sender pipeline (SC_HAS_SENDERguards in main.cpp/pipelines.cpp;--sendrefuses cleanly in such builds).scripts/install-receiver.shuses this to install on small ARM boards (user's Raspberry Pi Zero 2 W receiver) without PipeWire/portal deps, building SDL3 from source when the distro lacks it. Requires GCC 13+ (<format>). Validated on x86_64: builds, tests 5/5, --discover works, --send refuses with a message. -
systemd autostart:
systemd/screencast-receiver.serviceis a template (__SC_RECEIVER_BIN__/__SC_RECEIVER_USER__); the install script substitutes and enables it (opt out:SC_RECEIVER_SERVICE=0), adding the run user to video/render/input for headless KMSDRM. -
systemd tty trap (hit on the real Pi, reproduced locally): with StandardInput=tty + TTYPath=tty1, the service NEVER started — systemd blocks PRE-EXEC in acquire_terminal() waiting for a tty that the console session/getty already owns. Symptoms: status shows the main PID as "(screencast)" with Tasks:1 and ~40ms CPU, silent journal, no mDNS. Diagnosed by gdb-attaching the stuck process (acquire_terminal backtrace) after reproducing with a local transient unit. Fix: the service owns a dedicated free VT (tty7) + tolerant
ExecStartPre=-/usr/bin/chvt 7; tty1 keeps the console. Also:systemctl statusTasks counts THREADS (a healthy receiver shows ~15), and "Console Autologin" advice was WRONG (it made the hang deterministic) — removed from all docs. -
Fullscreen headless rendering: under the KMSDRM video driver (no window manager) the renderer goes fullscreen automatically; --fullscreen forces it on desktops. Aspect is preserved via SDL_SetRenderLogicalPresentation(LETTERBOX) (verified: 4:3 feed on a 3440x1440 monitor rendered with black pillarbox bars; --fullscreen window covered the full monitor). The service lifecycle was validated with a user unit: SIGTERM stop → clean exit (success), mDNS withdrawn, no restart.
-
mDNS operational lesson (hit during validation):
kill -9on a process holding an avahi registration leaves stale daemon records; the service then browses but never resolves (timeout for every peer) until records expire (~75 min).systemctl restart avahi-daemonclears it. Recorded in RUNBOOK; always stop with SIGTERM. -
Low-RAM build validated on hardware: the Zero 2 W receiver build completes with the install script's temporary-swap + single-job handling (the user confirmed; nlohmann/json's signaling TU was the OOM trigger before the fix). Cross-compiling on the dev machine was considered and dropped — the on-Pi build works and the toolchain/container effort was not needed.
-
Phase 5 (local UDP sender→receiver loopback) is implemented:
UdpRtpTransport(src/network/udp_transport.cpp): raw POSIX sockets, AF_INET, IPv4 via getaddrinfo; port 0 skips binding (sender side); stop() closes the socket to unblock the receive jthread. ASIO was deliberately deferred to Phase 6 (see decisions).screencastbinary (src/app/): cli.cpp + main.cpp + pipelines.cpp wiring SenderPipeline (capture→encode→packetize→send) and ReceiverPipeline (recv→depacketize→decode→bounded 3-frame queue→render thread).SdlRenderer(src/render/sdl_renderer.cpp): SDL3 window/renderer/texture, RGBA texture upload, texture recreated on resolution change.RendererFactory::createnow returnsRendererResult(error channel added, mirroring codec/capture patterns).- Encoder change: GLOBAL_HEADER removed so libx264 repeats SPS/PPS
in-band at every keyframe; a receiver now decodes from the bitstream
alone (mid-stream join, PLI recovery-ready).
get_extradata()is empty in this mode; codec round-trip test updated to match the streaming path. - Burst-control fixes after the first real run (receiver saw
non-existing PPS 0forever): without VBV, a 2256x1504 IDR burst (~100s of KB of back-to-back FU-A packets) overflowed the ~208KB default UDP receive buffer; the sequence gap made drop-on-damage discard whole keyframes including their in-band SPS/PPS, so the receiver never recovered. Fixes: encoder VBV (rc_max_rate = bitrate,rc_buffer_size = bitrate*2/fps— caps any keyframe to ~2 frame periods of bytes), microsecond encoder time_base (kills the 25fps pts quantization that duplicated timestamps), and a best-effort 4MB 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 agrimscreenshot 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 test4/4 — newudp loopbacktest encodes synthetic frames, packetizes, sends over a real localhost UDP socket, depacketizes, and decodes 10/10 frames with correct dimensions. Valgrind clean (loopback + codec tests). -
Manual validation pending (needs the desktop): run the two commands in
docs/RUNBOOK.md— receiver window should show the captured desktop. Tickdocs/PHASES.mdPhase 5 after this works. -
Phase 4 network framing done (RFC 3550 + RFC 6184 single-NAL/FU-A; 3-byte canonical start codes; drop-on-damage loss handling).
-
Phase 3 capture done and validated (PipeWire/portal backend; the
impl_ext_end_proxywrong-context warnings were fixed by holding the thread-loop lock across all pw proxy operations).
Decisions
- Language: C++20 with explicit modern-C++ guidelines in
AGENTS.mdandcpp-meson-build/SKILL.md. - Build system: Meson.
- Capture: PipeWire + xdg-desktop-portal.
- Encode/Decode: FFmpeg (libavcodec, libavutil, libswscale).
- H.264 encoder path: software
libx264, low-latency settings, Annex-B output. - SPS/PPS are sent in-band ahead of every keyframe (no GLOBAL_HEADER);
the decoder starts from the bitstream alone.
DecoderConfig.extradataremains available if signaling ever negotiates parameters out of band. - Transport: RTP over UDP via raw POSIX sockets for now; ASIO stays a Phase 6+ option (the ARCHITECTURE.md dependency table places it with signaling/discovery). IPv4 only at the transport level for now.
- Rendering: SDL3 (
sdl3pkg-config, 3.4 installed); plain texture upload, no GPU pipeline yet. - Pixel-format convention trap: FFmpeg names packed formats in memory byte order (AV_PIX_FMT_RGBA = R,G,B,A in memory), but SDL names 32-bit formats MSB-first (SDL_PIXELFORMAT_RGBA8888 = A,B,G,R in memory). FFmpeg RGBA data therefore needs SDL_PIXELFORMAT_ABGR8888 — using RGBA8888 paints the alpha byte as red (red-tinted image).
- Discovery: mDNS/Avahi.
- Namespace:
sc. - Module error results use per-module
std::variant<T, XError>types (CodecResult,CaptureResult,RendererResult) since C++20 has nostd::expected.
Active blockers
None.
Open questions
- GUI framework (Qt6 vs. none / CLI only) — deferred to later phase.
- Hardware acceleration strategy (VAAPI / Vulkan Video / NVENC) — evaluate after software encode works; note the Pi Zero 2 W receiver use case makes hardware decode (V4L2/MMAL) the more urgent half.
- IPv6 at the transport layer — RESOLVED in Phase 6: dual-stack everywhere (AF_UNSPEC resolution, IPV6_V6ONLY=0 listeners); validated over both families.
Forward-looking review notes (for later phases)
RtpTransport::start/sendreturn plain bools (scaffold API); error messages are lost — consider an error channel when signaling lands.- Very high bitrates can still exceed even a raised receive buffer if
net.core.rmem_maxis low on the receiver; the encoder VBV bounds bursts to ~2 frame periods, so this needs--bitrate≳ 100 Mbps to matter. Document in RUNBOOK. - DMA-BUF-only portal streams are rejected with a clear message (hardware path is Phase 7).
- No negative-path tests yet (bad config, bad stride, undersized buffer).
to_annex_b_h264sniffs AVCC vs Annex-B by content; if an AVCC-emitting encoder is ever added, prefer an explicit config flag over the heuristic.- Region targets are rejected: the desktop portal has no region capture.
CaptureSession::next_frame()returnsnullopton stream error without surfacing the reason (logged to stderr).- Receiver ignores unknown packetization modes (STAP-A/MTAP/FU-B); senders we control never emit them, but third-party interop would need support.