This fixes occasional flakiness when starting a session, where occasionally the client would erroneously report an "inbound UDP is most likely being blocked". Tested locally.
Claude-generated description:
Three fixes for RTSP launches failing before their stream starts, plus a refactor that makes this logic testable. The first fix was reproduced and verified on a real host. The second was found by code analysis and was not reproduced. The third came from a host log and was verified on a real host by injecting the failure.
| Commit | Change | Evidence |
|---|---|---|
fcbd15ea | Launch expires mid-handshake when ANNOUNCE is slow | Reproduced (5/5 failures before, 5/5 connected after) |
7558b1f4 | Extract pending-launch registry + component tests | Refactor, no behavior change; re-verified 5/5 |
f28d30f8 | Broadcast shutdown cancels the next stream's pending launch | Code analysis only, not reproduced; unit-tested policy |
a28621a4 | Session stopped during startup stalls RTSP and gets a 200 ANNOUNCE | Reproduced with fault injection (3/3 before, 3/3 fixed after); unit-tested policy |
1. Pending launch expires during a slow ANNOUNCE (reproduced)
A pending launch's ping_timeout (10s by default) starts when /launch queues it, but the client still needs that entry through ANNOUNCE, PLAY, and the control connection. Nothing paused the deadline once ANNOUNCE claimed the launch. If the client starts RTSP late and the ANNOUNCE startup worker then waits on the stream lifecycle gate, the entry expires mid-handshake:
- ANNOUNCE still returns 200 and a stream session starts.
- PLAY arrives on a new connection, hits
No pending session for incoming RTSP connection, and is closed (Moonlight reportsRTSP PLAY request failed: 10053). - The client never pings, so the host logs
Initial Ping Timeoutwith a misleading "inbound UDP is most likely being blocked" message.
Host log from a failing attempt (debug level):
13:42:21.526 Queued RTSP launch session 16 [pending launches: 1]
13:42:31.008 RTSP REQUEST seq=1 command=OPTIONS
13:42:31.061 RTSP REQUEST seq=6 command=ANNOUNCE
13:42:31.918 New streaming session started [active sessions: 1]
13:42:31.918 Event timeout: <unique_id>
13:42:31.918 RTSP RESPONSE seq=6 status=OK code=200
13:42:31.918 No pending session for incoming RTSP connection
13:42:41.068 Error: Initial Ping TimeoutFix: a claimed launch is marked as running and cannot expire while its startup worker owns it. When the session starts, the launch gets a fresh ping_timeout for PLAY and the control connection, which clears it as before. A failed or aborted startup returns the launch to its original deadline, and a guard covers every exit path of the worker. The expiry timer skips running startups, so a deadline that passed meanwhile can't make it fire in a loop; finishing a startup re-arms it.
Verification: Moonlight 6.1.0 streaming the Desktop app to the same Windows host. Before: 5 of 5 attempts failed as above. After: 5 of 5 connected, each with PLAY arriving after the original deadline. Re-run after the refactor in the next commit: 5 of 5 connected.
2. Refactor: pending-launch registry with component tests
Moves the pending-launch bookkeeping out of rtsp_server_t into launch_registry_t (src/rtsp_launch_registry.h), a header-only template with no locks or timers that takes the current time as a parameter. rtsp.cpp keeps its mutex, logging, timer and cleanup. Behavior is unchanged. The new test_component_rtsp_launch_registry (11 tests) includes a replay of the failing timeline above.
3. Broadcast shutdown cancels the next stream's pending launch (code analysis, not reproduced)
While the previous stream's broadcast is ending, the RTSP loop calls server.clear() on every iteration, and clear(all) runs cancel_pending_launches(). A launch queued for the next stream during that window is silently dropped, and its client then fails the handshake.
A pending launch belongs to the next broadcast and can't be harmed by keeping it: shared_t::release() holds the broadcast lock while end_broadcast() runs, so the new session's ref() waits until teardown finishes and start_broadcast() resets the flag. The loop now still clears every active session during broadcast shutdown but keeps pending launches; the decision is in pending_policy::rtsp_loop_clear(). /cancel, terminate_sessions() and process shutdown still cancel pending launches. cancel_pending_launches() now logs the count and reason, since these cancellations were previously invisible.
Not reproduced: eight rapid kill-and-reconnect attempts (0 to 4s gaps) never overlapped a queued launch with the broadcast teardown. On the test host the teardown finished in ~10ms while launches took ~8s to queue. The race needs a slower teardown, such as the control thread waiting on the lifecycle gate. Evidence for this fix is the code path above plus a unit test of the loop policy.
4. Session stopped during startup stalls RTSP and gets a 200 ANNOUNCE (reproduced with fault injection)
If a session is stopped before its client pings (here the app ended during startup), recv_ping still waited the full ping_timeout. join() waits on that thread, and when the RTSP loop is the joiner every RTSP reply waits too, including that launch's ANNOUNCE. The reply then went out as 200 for a session that no longer existed. The client went on to PLAY and a control connection that could only time out, and Moonlight showed Starting control stream establishment failed: Error 11 with "check your firewall ... UDP 47999". The host logged Initial Ping Timeout plus the "inbound UDP is most likely being blocked" hint, although the client had never been told to ping.
Host log (debug level, trimmed). The session was stopped by the app-replacement race fixed separately in #574:
17:20:11.819 RTSP REQUEST seq=6 command=ANNOUNCE
17:20:12.913 Stopping pending control session ... because the app terminated before the peer connected.
17:20:13.994 New streaming session started [active sessions: 1]
17:20:13.994 Waiting for video to end...
17:20:21.842 Error: Initial Ping Timeout
17:20:21.842 Error: No UDP datagrams reached the video socket ... Inbound UDP is most likely being blocked
17:20:22.280 RTSP RESPONSE seq=6 status=OK code=200
17:20:22.288 RTSP REQUEST seq=7 command=PLAYFix:
recv_pingwakes every 100ms and returns as soon as the session's shutdown event is raised. It logs that the session stopped instead of a ping timeout.- The ANNOUNCE reply checks the started session through a
weak_ptrand answers 503 if it is no longer running, so the client gets an immediate start failure instead of a firewall hint. The decision ispending_policy::announce_reply().
A session that stops just after a 200 has gone out still fails at the control connection, but no longer after an extra ~10s stall.
Verification (fault injection): an app that simply exits can't trigger this on a test host. The control thread can't see the exit while the ANNOUNCE startup holds the lifecycle gate, so the stop lands after the client connects. So both f28d30f8 and a28621a4 were built with the same never-committed patch that calls stream::session::stop() right after stream::session::start() succeeds in the ANNOUNCE startup worker, which is the state the logged failure was in. Each build ran three Desktop launches from Moonlight on the same host.
| ANNOUNCE reply | Stop → reply | PLAY sent | Host log | |
|---|---|---|---|---|
Before (f28d30f8), 3/3 | 200 OK | 9.1–9.9s | yes | Initial Ping Timeout + "Inbound UDP is most likely being blocked" |
After (a28621a4), 3/3 | 503 | 0.4–0.5s | no | Session stopped before the client's video ping arrived |
After the fix, recv_ping returned 8–30ms after the stop and the session finished tearing down in ~0.4s. The reply still waits for that join on the RTSP thread, but the join is now short. Moonlight's on-screen error wasn't captured.
Testing notes
- Unit tests (
test_component_rtsp_launch_registry, plus new cases intest_rtsp_pending_policy, 12 passing after commit 4) were run with a direct g++/GoogleTest build. Configuringtests/standalone fails locally on an unrelatednlohmann_jsontarget, so they haven't run through CMake/CI yet. clang-format20 (the version pinned inpyproject.toml) reports no new differences.- The test host's Moonlight took ~9.5s from the
/launchresponse to its first RTSP request because it addressed the host by a name that resolves to IPv6 addresses first, while the host listened on IPv4 only (each refused test connect costs ~2s on Windows). That's what made the slow-ANNOUNCE case easy to reproduce. Neither fix changes the 10s window before the first RTSP request.
🤖 Generated with Claude Code using Claude Opus 5.5 (high effort)
Activity
10Please retarget this PR to canary and resolve any resulting conflicts on your branch so the changes can be reviewed against the intended integration target.
@Nonary Both requested changes are addressed: the stopped-before-control cleanup (3a1204e9) and the broadcast-shutdown cleanup (1545057d, which clears each removed session's launch by ID while unrelated pending launches survive), each with registry and pending-policy tests. The branch is rebased onto the current canary (a8ef56b0). Could you take another look when you have a moment?
🤖 Generated with Claude Code
@Nonary Addressed in ad24bcea. The last-session join now ignores the joined session's own launch when it decides whether to pause (rtsp_stream::has_other_pending_launch_or_startup(), backed by launch_registry_t::contains_other_than()). That launch still sits in the registry until clear() posts its session_clear(). An unrelated pending launch, such as one queued for the next broadcast, still defers the pause. This also covers an ordinary stop before control, not only broadcast shutdown, since both release the launch after join().
I added two teardown tests: LastSessionTeardownIgnoresItsOwnRenewedLaunch (the pause runs) and LastSessionTeardownDefersToAnUnrelatedLaunch (the pause is deferred, and the unrelated launch survives clearing the removed one). All 28 registry and pending-policy tests pass, and rtsp.cpp and stream.cpp compile in the MinGW build. I have not reproduced this on a live host. Could you take another look?
🤖 Generated with Claude Code
@Nonary Thanks. The batch case is addressed in adbd349e.
- Teardown batch:
clear()now records the launch IDs of every session it is about to join in_joining_launch_ids(under_launch_sessions_mutex) and removes them once all the joins finish.has_other_pending_launch_or_startup()ignores those IDs as well as the caller's own, so a batch of sessions that stopped before control no longer defers the pause on its own launches. A launch that belongs to no session in the batch, such as one queued for the next broadcast, still defers it. - Production teardown test: I extended
test_component_host_stream_terminal_composition, since it already compiles the realclear()andsession_clear(). Itsjoin()now runs the actualrtsp_pendingquery extracted fromstream.cpp, and each case drivesclear()through the realrtsp_loop_clear()policy. Four new checks cover broadcast shutdown and stopped-session reaping of two sessions without control, each with and without a separate pending launch: the app pauses exactly once and both launches are cleared, or the pause is deferred and the next launch survives. Againstad24bcea's own-ID-only check, both "pauses once" cases fail, and with this commit all four pass. - A fix I should flag: this PR had broken that composition test. It still modeled
_launch_sessionsas astd::vectorand couldn't compile against the new registry. The fixture now uses the reallaunch_registry_tand the realannounce_replyandclear_removed_session_launchpolicies, and every pre-existing check passes as it does oncanary. - Startup reservation: understood that it is pre-existing and not blocking. I have left it unchanged here.
The 28 registry and pending-policy tests pass (the two registry teardown tests now cover a two-session batch), as do the composition test on Linux and the stream_disconnect_lifecycle, paused_display_restore and control_peer_timeout scripts. rtsp.cpp and stream.cpp compile in the MinGW build. I have not reproduced this on a live host. Could you take another look?
🤖 Generated with Claude Code
Rebased onto current
master(0e5ff426) and squashed into a single commit, sincertsp.cpphad changed substantially upstream. The fixes are the same; the commit hashes in the description refer to the pre-rebase history.One interaction worth calling out: the new
retain_pending_runtime_holds()checkedentry.expires_at > now. With this PR, a launch whose startup worker is still running deliberately keeps its original deadline, which may already have passed. That's the slow-ANNOUNCE case in fix 1. Left as-is, that check would have discarded exactly those launches as "canceled or expired". It now uses the registry'sis_live(), like the rest of the pending-launch code. Launches removed through the registry are also collected intoremoved_pending_sessions, so their audio and display-power holds are released as upstream intended.Verified on the rebased tree: built with MSYS2 UCRT64,
test_rtsp_launch_registryandtest_rtsp_pending_policypass, and a Moonlight stream to the same Windows host queued, started and connected normally.🤖 Generated with Claude Code