diff --git a/internal/version/version.go b/internal/version/version.go
index 0dea23f65..1f271694c 100644
--- a/internal/version/version.go
+++ b/internal/version/version.go
@@ -15,7 +15,7 @@ func VersionMinor() int {
}
func VersionRevision() int {
- return 1
+ return 2
}
func Version() string {
diff --git a/skills/srs-develop/SKILL.md b/skills/srs-develop/SKILL.md
index a581575a8..a252e9345 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 81edb6763..b10cb60e4 100644
--- a/trunk/doc/CHANGELOG.md
+++ b/trunk/doc/CHANGELOG.md
@@ -7,6 +7,7 @@ The changelog for SRS.
## SRS 8.0 Changelog
+* v8.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. v8.0.2 (#4678)
* v8.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. v8.0.1 (#4676)
* v8.0, 2026-05-17, Init SRS 8.0, code Free. v8.0.0
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_version8.hpp b/trunk/src/core/srs_core_version8.hpp
index 4b2c74f75..ac376c261 100644
--- a/trunk/src/core/srs_core_version8.hpp
+++ b/trunk/src/core/srs_core_version8.hpp
@@ -9,6 +9,6 @@
#define VERSION_MAJOR 8
#define VERSION_MINOR 0
-#define VERSION_REVISION 1
+#define VERSION_REVISION 2
#endif