From 133f66afba677ef68508b2d3cde8e73067bf4d92 Mon Sep 17 00:00:00 2001 From: winlin Date: Tue, 19 May 2026 11:20:25 -0400 Subject: [PATCH] Edge: Fix HTTP-FLV 404 and RTMP late-join missing sequence headers. v7.0.150 (#4678) Cherry-pick of the v8.0.2 fix to the 7.0 release line. Two edge-cluster regressions surface when validating an RTMP origin/edge setup: - **HTTP-FLV play on edge always 404'd.** `SrsHttpStreamServer::assemble()` registered the dynamic matcher only when the mux cast was `NULL` (inverted guard), so the matcher was never wired up. On edge the FLV mount is created lazily by the dynamic matcher, so every HTTP-FLV client got 404. Invert the guard to register when the mux is valid, mirroring the destructor. (`trunk/src/app/srs_app_http_stream.cpp`) - **RTMP players that join an edge stream after the first player fail to decode.** After v7.0.94 (#4513) stopped creating `SrsOriginHub` on edge, the `hub_active` gate in `SrsLiveSource::consumer_dumps()` always evaluated false on edge. That gate guards the dump of cached `onMetaData` + AVC sequence header + AAC sequence header + GOP cache to a new consumer. Result: the first player attaches before the edge-pull starts and gets headers via the live fan-out, but every subsequent player gets coded payload with no codec config and ffmpeg aborts with `dimensions not set` / `Could not write header`. Fall back to the meta cache state when `hub_` is `NULL`, so the dump path runs once the edge-pull has populated the cache. (`trunk/src/app/srs_app_rtmp_source.cpp`) Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/version/version.go | 2 +- skills/srs-develop/SKILL.md | 4 + .../scripts/proxy-e2e-edge-test.sh | 342 ++++++++++++++++++ trunk/conf/edge-for-proxy.conf | 39 ++ trunk/conf/origin-for-edge.conf | 19 + trunk/doc/CHANGELOG.md | 1 + trunk/src/app/srs_app_http_stream.cpp | 2 +- trunk/src/app/srs_app_rtmp_source.cpp | 5 +- trunk/src/core/srs_core_version7.hpp | 2 +- 9 files changed, 412 insertions(+), 4 deletions(-) create mode 100755 skills/srs-develop/scripts/proxy-e2e-edge-test.sh create mode 100644 trunk/conf/edge-for-proxy.conf create mode 100644 trunk/conf/origin-for-edge.conf diff --git a/internal/version/version.go b/internal/version/version.go index daf50db42..aebe8b4ad 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -15,7 +15,7 @@ func VersionMinor() int { } func VersionRevision() int { - return 149 + return 150 } func Version() string { diff --git a/skills/srs-develop/SKILL.md b/skills/srs-develop/SKILL.md index 6602e1fe0..3ab1e921f 100644 --- a/skills/srs-develop/SKILL.md +++ b/skills/srs-develop/SKILL.md @@ -152,6 +152,10 @@ Only after the user confirms the routing do you proceed to Step 2. ``` bash scripts/proxy-e2e-cluster-test.sh ``` + - Proxy + SRS edge + SRS origin three-tier topology (starts proxy + one SRS edge in `mode remote` registered with the proxy + one upstream SRS origin, publishes RTMP via proxy→edge→origin, then plays the same stream with two concurrent RTMP players where the second joins after a delay as a late joiner on the active edge-pull): + ``` + bash scripts/proxy-e2e-edge-test.sh + ``` - Redis multi-proxy routing test (requires local Redis; starts two proxy instances with Redis LB, publishes through one proxy, verifies playback through the other): ``` bash scripts/proxy-e2e-redis-test.sh diff --git a/skills/srs-develop/scripts/proxy-e2e-edge-test.sh b/skills/srs-develop/scripts/proxy-e2e-edge-test.sh new file mode 100755 index 000000000..771203e77 --- /dev/null +++ b/skills/srs-develop/scripts/proxy-e2e-edge-test.sh @@ -0,0 +1,342 @@ +#!/bin/bash +# E2E test for proxy + edge + origin: starts proxy + one SRS edge (registered +# with proxy) + one SRS upstream origin (not registered). Publishes a single +# RTMP stream through proxy -> edge -> origin, then plays the stream twice +# concurrently from the proxy -> edge with a delay so the second player is a +# late joiner on an already-active edge-pull. Both players must succeed. +set -e + +SCRIPT_DIR="$(cd -P "$(dirname "$0")" && pwd)" +# Walk up from SCRIPT_DIR looking for go.mod. This avoids brittle "../../../.." +# counting when the skills directory is reached via a symlink (which changes +# the symbolic vs. physical depth). +WORKSPACE="$SCRIPT_DIR" +while [[ "$WORKSPACE" != "/" && ! -f "$WORKSPACE/go.mod" ]]; do + WORKSPACE="$(dirname "$WORKSPACE")" +done + +if [[ ! -f "$WORKSPACE/go.mod" ]]; then + echo "Error: go.mod not found walking up from: $SCRIPT_DIR" >&2 + exit 1 +fi + +# Proxy ports — high range, avoids the SRS port range. +PROXY_RTMP_PORT=11935 +PROXY_HTTP_API_PORT=11985 +PROXY_HTTP_SERVER_PORT=18080 +PROXY_WEBRTC_PORT=18000 +PROXY_SRT_PORT=20080 +PROXY_SYSTEM_API_PORT=12025 + +# Origin ports (from origin-for-edge.conf) — upstream of the edge, NOT +# registered with the proxy. Distinct from origin1/2/3 to avoid collisions +# when running this test alongside the other proxy E2E tests. +ORIGIN_RTMP_PORT=19360 +ORIGIN_API_PORT=19860 + +# Edge ports (from edge-for-proxy.conf) — what the proxy treats as its backend. +EDGE_RTMP_PORT=19361 +EDGE_HTTP_PORT=8091 +EDGE_API_PORT=19861 + +SOURCE_FLV="$WORKSPACE/trunk/doc/source.flv" +SRS_BINARY="$WORKSPACE/trunk/objs/srs" +ORIGIN_CONF="$WORKSPACE/trunk/conf/origin-for-edge.conf" +EDGE_CONF="$WORKSPACE/trunk/conf/edge-for-proxy.conf" +# Randomize per run so each invocation starts from clean state and never +# shares state with sibling E2E tests publishing to live/livestream. +STREAM_NAME="edge$(date +%s)" +STREAM_PATH="live/$STREAM_NAME" + +# PIDs to clean up on exit. +PROXY_PID="" +ORIGIN_PID="" +EDGE_PID="" +PUBLISH_PID="" +PLAYER1_PID="" +PLAYER2_PID="" + +cleanup() { + echo "" + echo "=== Cleaning up ===" + for pid in $PUBLISH_PID $PLAYER1_PID $PLAYER2_PID $EDGE_PID $ORIGIN_PID $PROXY_PID; do + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + fi + done + sleep 1 + for pid in $PUBLISH_PID $PLAYER1_PID $PLAYER2_PID $EDGE_PID $ORIGIN_PID $PROXY_PID; do + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + kill -9 "$pid" 2>/dev/null || true + fi + done + echo "Cleanup done." +} +trap cleanup EXIT + +wait_for_http() { + local url=$1 + local name=$2 + local i + + for i in $(seq 1 30); do + if curl -fsS --max-time 2 "$url" >/dev/null 2>&1; then + echo "$name is ready." + return 0 + fi + sleep 1 + done + + echo "Error: $name is not ready after 30s: $url" >&2 + return 1 +} + +api_has_stream() { + local api_port=$1 + local stream=$2 + + curl -fsS --max-time 3 "http://127.0.0.1:$api_port/api/v1/streams/" 2>/dev/null | grep -q "$stream" +} + +verify_probe_has_av() { + local url=$1 + local label=$2 + local probe_output + + probe_output=$(ffprobe -v error -rw_timeout 5000000 -show_streams "$url" 2>&1 || true) + + if ! echo "$probe_output" | grep -q "codec_type=video"; then + echo "FAIL: No video stream detected for $label." >&2 + echo "ffprobe output:" >&2 + echo "$probe_output" >&2 + exit 1 + fi + + if ! echo "$probe_output" | grep -q "codec_type=audio"; then + echo "FAIL: No audio stream detected for $label." >&2 + echo "ffprobe output:" >&2 + echo "$probe_output" >&2 + exit 1 + fi + + echo "PASS: Audio/video detected for $label." +} + +echo "=== E2E Proxy + Edge + Origin Test ===" +echo "Workspace: $WORKSPACE" +echo "Stream: $STREAM_PATH" +echo "" + +# --- Pre-checks --- +if [[ ! -f "$SOURCE_FLV" ]]; then + echo "Error: test source not found: $SOURCE_FLV" >&2 + exit 1 +fi +if [[ ! -f "$ORIGIN_CONF" ]]; then + echo "Error: origin conf not found: $ORIGIN_CONF" >&2 + exit 1 +fi +if [[ ! -f "$EDGE_CONF" ]]; then + echo "Error: edge conf not found: $EDGE_CONF" >&2 + exit 1 +fi +for tool in ffmpeg ffprobe curl; do + if ! command -v "$tool" &>/dev/null; then + echo "Error: $tool not found in PATH" >&2 + exit 1 + fi +done + +# --- Step 0: Clean up stale state --- +rm -f "$WORKSPACE/trunk/objs/origin-for-edge.pid" "$WORKSPACE/trunk/objs/edge-for-proxy.pid" +ALL_PORTS="$PROXY_RTMP_PORT $PROXY_HTTP_API_PORT $PROXY_HTTP_SERVER_PORT $PROXY_WEBRTC_PORT $PROXY_SRT_PORT $PROXY_SYSTEM_API_PORT" +ALL_PORTS="$ALL_PORTS $ORIGIN_RTMP_PORT $ORIGIN_API_PORT $EDGE_RTMP_PORT $EDGE_HTTP_PORT $EDGE_API_PORT" +for port in $ALL_PORTS; do + lsof -ti :"$port" 2>/dev/null | xargs kill 2>/dev/null || true +done +sleep 1 + +# --- Step 1: Build proxy --- +echo "=== Step 1: Building proxy ===" +cd "$WORKSPACE" +make -s 2>&1 +echo "Proxy built: $WORKSPACE/bin/srs-proxy" + +# --- Step 2: Build SRS (if not already built) --- +if [[ ! -f "$SRS_BINARY" ]]; then + echo "=== Step 2: Building SRS ===" + cd "$WORKSPACE/trunk" + ./configure && make 2>&1 | tail -3 + echo "SRS built: $SRS_BINARY" +else + echo "=== Step 2: SRS already built ===" +fi + +# --- Step 3: Start proxy --- +echo "=== Step 3: Starting proxy (RTMP :$PROXY_RTMP_PORT, System API :$PROXY_SYSTEM_API_PORT) ===" +cd "$WORKSPACE" +env PROXY_RTMP_SERVER=$PROXY_RTMP_PORT \ + PROXY_HTTP_API=$PROXY_HTTP_API_PORT \ + PROXY_HTTP_SERVER=$PROXY_HTTP_SERVER_PORT \ + PROXY_WEBRTC_SERVER=$PROXY_WEBRTC_PORT \ + PROXY_SRT_SERVER=$PROXY_SRT_PORT \ + PROXY_SYSTEM_API=$PROXY_SYSTEM_API_PORT \ + PROXY_LOAD_BALANCER_TYPE=memory \ + ./bin/srs-proxy >/tmp/srs-proxy-edge-e2e.log 2>&1 & +PROXY_PID=$! +echo "Proxy PID: $PROXY_PID" + +wait_for_http "http://127.0.0.1:$PROXY_SYSTEM_API_PORT/api/v1/versions" "Proxy System API" + +if ! kill -0 "$PROXY_PID" 2>/dev/null; then + echo "Error: proxy failed to start. Logs:" >&2 + cat /tmp/srs-proxy-edge-e2e.log >&2 + exit 1 +fi +echo "Proxy started." + +# --- Step 4: Start upstream origin (no proxy heartbeat) --- +echo "=== Step 4: Starting upstream SRS origin (RTMP :$ORIGIN_RTMP_PORT) ===" +ulimit -n 10000 2>/dev/null || true +cd "$WORKSPACE/trunk" +./objs/srs -c conf/origin-for-edge.conf >/tmp/srs-origin-edge-e2e.log 2>&1 & +ORIGIN_PID=$! +echo "Origin PID: $ORIGIN_PID" + +wait_for_http "http://127.0.0.1:$ORIGIN_API_PORT/api/v1/versions" "Origin HTTP API" + +if ! kill -0 "$ORIGIN_PID" 2>/dev/null; then + echo "Error: origin failed to start. Logs:" >&2 + cat /tmp/srs-origin-edge-e2e.log >&2 + exit 1 +fi +echo "Origin started." + +# --- Step 5: Start edge (mode remote, registered with proxy) --- +echo "=== Step 5: Starting SRS edge (RTMP :$EDGE_RTMP_PORT, upstream :$ORIGIN_RTMP_PORT) ===" +./objs/srs -c conf/edge-for-proxy.conf >/tmp/srs-edge-e2e.log 2>&1 & +EDGE_PID=$! +echo "Edge PID: $EDGE_PID" + +wait_for_http "http://127.0.0.1:$EDGE_API_PORT/api/v1/versions" "Edge HTTP API" + +# Wait for the edge to register with the proxy (heartbeat interval is 9s). +echo "Waiting for edge to register with proxy (up to 20s)..." +for i in $(seq 1 20); do + if grep -q "Register SRS media server" /tmp/srs-proxy-edge-e2e.log 2>/dev/null; then + echo "Edge registered with proxy." + break + fi + sleep 1 +done + +if ! grep -q "Register SRS media server" /tmp/srs-proxy-edge-e2e.log 2>/dev/null; then + echo "Error: edge did not register with proxy after 20s. Proxy logs:" >&2 + cat /tmp/srs-proxy-edge-e2e.log >&2 + exit 1 +fi + +if ! kill -0 "$EDGE_PID" 2>/dev/null; then + echo "Error: edge failed to start. Logs:" >&2 + cat /tmp/srs-edge-e2e.log >&2 + exit 1 +fi +echo "Edge started and registered." + +# --- Step 6: Publish RTMP stream to proxy --- +# Path: ffmpeg -> proxy (:$PROXY_RTMP_PORT) -> edge (:$EDGE_RTMP_PORT, mode remote forwards +# publish) -> origin (:$ORIGIN_RTMP_PORT). Verify the publish reached the +# upstream origin via the origin's HTTP API. +echo "=== Step 6: Publishing $STREAM_PATH through proxy -> edge -> origin ===" +ffmpeg -stream_loop -1 -re -i "$SOURCE_FLV" -c copy -f flv \ + "rtmp://localhost:$PROXY_RTMP_PORT/$STREAM_PATH" >/tmp/srs-ffmpeg-edge-e2e.log 2>&1 & +PUBLISH_PID=$! +echo "Publisher PID: $PUBLISH_PID" + +echo "Waiting for stream to reach origin (up to 15s)..." +reached_origin=0 +for i in $(seq 1 15); do + if api_has_stream "$ORIGIN_API_PORT" "$STREAM_NAME"; then + reached_origin=1 + echo "Stream visible on origin after ${i}s." + break + fi + sleep 1 +done + +if [[ $reached_origin -ne 1 ]]; then + echo "FAIL: stream did not reach upstream origin via edge." >&2 + echo "Publisher logs:" >&2 + cat /tmp/srs-ffmpeg-edge-e2e.log >&2 + echo "Edge logs:" >&2 + cat /tmp/srs-edge-e2e.log >&2 + exit 1 +fi + +if ! kill -0 "$PUBLISH_PID" 2>/dev/null; then + echo "Error: publisher exited unexpectedly. Logs:" >&2 + cat /tmp/srs-ffmpeg-edge-e2e.log >&2 + exit 1 +fi +echo "PASS: publish path proxy -> edge -> origin works." + +# --- Step 7: Two concurrent players on the same stream --- +# Player 1 attaches first and triggers the edge-pull from origin. Player 2 +# joins a few seconds later as a late joiner on the already-active edge-pull. +# Both must succeed — this is the proxy-side analogue of the C++ edge late- +# join fix. +echo "=== Step 7: Two concurrent RTMP players via proxy ===" +PLAY_DURATION=8 +PLAYER_URL="rtmp://localhost:$PROXY_RTMP_PORT/$STREAM_PATH" + +echo "Starting player 1 (immediate)..." +ffmpeg -rw_timeout 5000000 -i "$PLAYER_URL" -t $PLAY_DURATION -c copy -f flv -y /dev/null \ + >/tmp/srs-player1-edge-e2e.log 2>&1 & +PLAYER1_PID=$! + +sleep 3 + +echo "Starting player 2 (late joiner, +3s)..." +ffmpeg -rw_timeout 5000000 -i "$PLAYER_URL" -t $PLAY_DURATION -c copy -f flv -y /dev/null \ + >/tmp/srs-player2-edge-e2e.log 2>&1 & +PLAYER2_PID=$! + +# Wait for both players to finish. +player1_rc=0 +player2_rc=0 +wait "$PLAYER1_PID" || player1_rc=$? +wait "$PLAYER2_PID" || player2_rc=$? +# Clear PIDs so cleanup() doesn't try to re-kill exited processes. +PLAYER1_PID="" +PLAYER2_PID="" + +check_player() { + local label=$1 + local rc=$2 + local log=$3 + + if [[ $rc -ne 0 ]]; then + echo "FAIL: $label exited with code $rc. Logs:" >&2 + cat "$log" >&2 + exit 1 + fi + # Decoded-frames check — ffmpeg's progress lines contain `frame=` once it has + # successfully started decoding. Catches "dimensions not set"-style failures + # where ffmpeg returns 0 but never produced output. + if ! grep -qE 'frame= *[1-9]' "$log"; then + echo "FAIL: $label produced no frames. Logs:" >&2 + cat "$log" >&2 + exit 1 + fi + echo "PASS: $label played successfully." +} + +check_player "player 1" "$player1_rc" /tmp/srs-player1-edge-e2e.log +check_player "player 2 (late joiner)" "$player2_rc" /tmp/srs-player2-edge-e2e.log + +# --- Step 8: Final probe via proxy confirms A/V is still queryable --- +echo "=== Step 8: Final ffprobe verification via proxy ===" +verify_probe_has_av "rtmp://localhost:$PROXY_RTMP_PORT/$STREAM_PATH" "proxy $STREAM_PATH" + +echo "" +echo "=== E2E Proxy + Edge + Origin Test PASSED ===" diff --git a/trunk/conf/edge-for-proxy.conf b/trunk/conf/edge-for-proxy.conf new file mode 100644 index 000000000..f6fc433cc --- /dev/null +++ b/trunk/conf/edge-for-proxy.conf @@ -0,0 +1,39 @@ + +# Edge for the proxy+edge E2E test (skills/srs-develop). +# Registers with the proxy via heartbeat, and in mode remote forwards publishes +# to and pulls plays from origin-for-edge.conf (RTMP :19360). + +max_connections 1000; +pid objs/edge-for-proxy.pid; +daemon off; +srs_log_tank console; + +rtmp { + listen 19361; +} +http_server { + enabled on; + listen 8091; + dir ./objs/nginx/html; +} +http_api { + enabled on; + listen 19861; +} +heartbeat { + enabled on; + interval 9; + url http://127.0.0.1:12025/api/v1/srs/register; + device_id edge-for-proxy; + ports on; +} +vhost __defaultVhost__ { + cluster { + mode remote; + origin 127.0.0.1:19360; + } + http_remux { + enabled on; + mount [vhost]/[app]/[stream].flv; + } +} diff --git a/trunk/conf/origin-for-edge.conf b/trunk/conf/origin-for-edge.conf new file mode 100644 index 000000000..d250aceb4 --- /dev/null +++ b/trunk/conf/origin-for-edge.conf @@ -0,0 +1,19 @@ + +# Upstream origin for the proxy+edge E2E test (skills/srs-develop). +# Not registered with the proxy; the edge in front of it is. The edge pulls +# from this origin on play and pushes publishes here on publish. + +max_connections 1000; +pid objs/origin-for-edge.pid; +daemon off; +srs_log_tank console; + +rtmp { + listen 19360; +} +http_api { + enabled on; + listen 19860; +} +vhost __defaultVhost__ { +} diff --git a/trunk/doc/CHANGELOG.md b/trunk/doc/CHANGELOG.md index 5ca1c897e..4d69b44cd 100644 --- a/trunk/doc/CHANGELOG.md +++ b/trunk/doc/CHANGELOG.md @@ -7,6 +7,7 @@ The changelog for SRS. ## SRS 7.0 Changelog +* v7.0, 2026-05-19, Merge [#4678](https://github.com/ossrs/srs/pull/4678): Edge: Fix HTTP-FLV 404 and RTMP late-join missing sequence headers. v7.0.150 (#4678) * v7.0, 2026-05-17, Merge [#4676](https://github.com/ossrs/srs/pull/4676): Proxy: Fix RTC/SRT reader goroutine leak; unwrap legacy WHEP JSON envelope; add WHEP pprof guide. v7.0.149 (#4676) * v7.0, 2026-05-17, Merge [#4675](https://github.com/ossrs/srs/pull/4675): Proxy: Refactor for testability; add SRT/WHIP E2E and unit tests. v7.0.148 (#4675) * v7.0, 2026-05-02, Merge [#4672](https://github.com/ossrs/srs/pull/4672): Proxy: Refactor server APIs and expand RTMP test coverage. v7.0.147 (#4672) diff --git a/trunk/src/app/srs_app_http_stream.cpp b/trunk/src/app/srs_app_http_stream.cpp index 4e141e4c2..d154da9b0 100644 --- a/trunk/src/app/srs_app_http_stream.cpp +++ b/trunk/src/app/srs_app_http_stream.cpp @@ -1076,7 +1076,7 @@ SrsHttpStreamServer::SrsHttpStreamServer() void SrsHttpStreamServer::assemble() { SrsHttpServeMux *mux = dynamic_cast(mux_); - if (!mux) { + if (mux) { mux->add_dynamic_matcher(this); } } diff --git a/trunk/src/app/srs_app_rtmp_source.cpp b/trunk/src/app/srs_app_rtmp_source.cpp index 724fc49f7..6731a1d2a 100644 --- a/trunk/src/app/srs_app_rtmp_source.cpp +++ b/trunk/src/app/srs_app_rtmp_source.cpp @@ -2559,7 +2559,10 @@ srs_error_t SrsLiveSource::consumer_dumps(ISrsLiveConsumer *consumer, bool ds, b } // If stream is publishing, dumps the sequence header and gop cache. - bool hub_active = hub_ ? hub_->active() : false; + // On edge, hub_ is NULL; the source is "publishing" once the edge-pull has + // populated the meta cache. Late-joining consumers must still receive the + // cached metadata + sequence headers + GOP via this path. + bool hub_active = hub_ ? hub_->active() : (meta_->data() || meta_->vsh() || meta_->ash()); if (hub_active) { // Copy metadata and sequence header to consumer. if ((err = meta_->dumps(consumer, atc_, jitter_algorithm_, dm, ds)) != srs_success) { diff --git a/trunk/src/core/srs_core_version7.hpp b/trunk/src/core/srs_core_version7.hpp index 9f23535d6..99af50433 100644 --- a/trunk/src/core/srs_core_version7.hpp +++ b/trunk/src/core/srs_core_version7.hpp @@ -9,6 +9,6 @@ #define VERSION_MAJOR 7 #define VERSION_MINOR 0 -#define VERSION_REVISION 149 +#define VERSION_REVISION 150 #endif