* rtc: keep the participant egress guard across a duplicate identity eviction
Room.RemoveParticipant clears hasPublished[identity], which is the guard that
stops auto participant egress launching twice for the same identity. A duplicate
identity eviction calls RemoveParticipant with the outgoing participant's own ID,
so the "participant session has been replaced" early return does not apply and
the guard is dropped even though the identity stays in the room.
The replacement session then publishes, finds no guard and launches a second
participant egress. The first one does not stop: the egress SDK resolves its
source by identity, so it follows the new SID, re-subscribes to the republished
tracks and keeps recording. Both jobs then write the same media until the
participant leaves for good.
Keep the guard when the close reason is ParticipantCloseReasonDuplicateIdentity.
launchedTrackEgresses is left as is, since it is keyed per track ID and a
replacement session republishes with new track IDs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* rtc: separate the participant egress launch guard from hasPublished
Review feedback on the first commit: hasPublished gates the publisher agent
dispatch as well as participant egress, so preserving it across a duplicate
identity eviction left the replacement session without its publisher jobs.
Track the participant egress launch in its own identity keyed map. hasPublished
is cleared on every removal as before, so agent dispatch is untouched, and only
the egress guard survives a duplicate identity eviction.
The preserved guard is also tied to a successful handoff: StartSession releases
it when the session that caused the eviction fails to join, so it cannot strand
an identity that is no longer in the room.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* rtc: only release a preserved egress guard while the identity is unoccupied
Sessions for one identity can race. A handoff fails with ErrAlreadyJoined
exactly when another session has already joined under that identity, and that
session now owns the preserved participant egress guard. Releasing it there let
the winner start a second egress on its next publication, alongside the job
still recording.
Release the guard only while the room holds no participant for the identity.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* rtc: drop the failed handoff release of the egress guard
Review feedback: the duplicate identity disconnect is filtered, so the running
participant egress keeps recording whether or not the replacement session ever
joins. Releasing the guard on a failed handoff only gives the next session for
that identity a way to launch a second job against the same tracks.
Remove ClearLaunchedParticipantEgress and its StartSession deferred call. The
guard is now released solely by RemoveParticipant on a non-duplicate removal or
a room close, which is where the identity genuinely leaves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* sfu: don't count opus useinbandfec=0 as FEC in the down track
DownTrack decides whether opus FEC is on by looking for "fec" anywhere
in the fmtp line, both on Bind and when the upstream codec changes.
"useinbandfec=0" contains "fec", so a publisher that turns FEC off is
scored as if FEC were on. The connection quality scorer then lowers the
packet loss weight for opus from 8.0 to 5.33, and a subscriber with 8%
audio loss is reported as GOOD instead of POOR. The receiver side
already checks for useinbandfec=1, so the publisher's and subscribers'
scores disagree about the same stream.
Add utils.IsOpusInbandFECEnabled, which reads the useinbandfec
parameter by key and treats only a value of 1 as on, and use it for
all three FEC checks in the down track. The RED path already looked for
useinbandfec=1 and only gains tolerance of spaces around the parameter.
* sfu: read FEC from the upstream codec when the down track's codec changes
handleUpstreamCodecChange took the FEC flag from the codec the subscriber
negotiated, while Bind takes it from the upstream codec. FEC describes
what the publisher encodes, so after RED falls back to opus, a publisher
with useinbandfec=1 and a subscriber with useinbandfec=0 was scored as
having no FEC, and the reverse as having FEC.
Decide FEC in one place from the upstream codecs, using the upstream opus
codec for RED, and use it in both Bind and the codec change path. Log the
flag on codec change as Bind already does.
* sfu: use the same useinbandfec=1 check as the RED path for FEC
Drop utils.IsOpusInbandFECEnabled and check FEC with
strings.Contains(strings.ToLower(fmtp), "useinbandfec=1"), as the RED
path already did.
* sfu: move the FEC check to utils and match useinbandfec with a regex
Move the check to utils.IsFECEnabled, as asked in review, and match
useinbandfec=1 as one fmtp parameter with a regex, ignoring case and
spaces around the parameter and the "=". A publisher that sends
"minptime=10; useinbandfec = 1" was scored as having no FEC. Move the
tests to utils/helpers_test.go.
* agent dispatch: carry thread_id from dispatches to jobs
* agent dispatch: validate thread_id as an AT_ agent thread id on the API
* agent dispatch: validate thread_id with protocol agent.ValidateThreadID
* agent dispatch: report an invalid thread_id as a Twirp InvalidArgument on thread_id
Since #4453, StreamTrackerManager.UpdateTrackInfo calls
SetPaused(trackInfo.Muted) on every track info update, not only when
the mute state changes. StreamTracker.SetPaused(false) reset the tracker
even when it was not paused, so any track info update of an unmuted
video track (video size, codec info, simulcast layer SSRCs,
UpdateLocalVideoTrack) marked every live layer stopped, removed it from
the available layers, and zeroed its bitrate until the next packet and
bitrate report.
SetPaused now returns early when the paused state does not change, as
StreamTrackerDependencyDescriptor.SetPaused already does. A new tracker
starts unpaused in its reset state, so the SetPaused call in AddTracker
behaves the same.
* sfu: restart the dependency descriptor parser of a publisher stream after a gap
libwebrtc numbers dependency descriptor frames with one counter across all
simulcast layers. A layer that dynacast paused for a long time resumes with a
frame number more than half the 16 bit range away from its last one. The
publisher buffer's parser never restarted, so it unwrapped the resumed frames
against the stale number and later lost a full wrap. Every packet of the layer
then fell below the structure thresholds and was dropped until the numbers
caught up, about 16 minutes for three 22 fps layers.
Enable the parser restart after a 30 s gap that relayed tracks already use, and
start the key frame seeder on a restart, as the restart drops the structure the
following packets need.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* sfu: request a key frame only when a restarted stream has no structure
The parser now reports the restart on the packet that caused it, so the
buffer does not read the parser's state. A stream that resumes with a key
frame carries its structure and needs no key frame request.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A second Close returns at once while the first still runs the close
callbacks. CloseDone is closed after they have run, so a caller can act
only once the participant's final update went out.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* rtc: don't let the forwarded RPC timeout underflow
PerformRpc forwarded "responseTimeout - p.lastRTT" to the callee, both
unsigned. A caller timeout below the callee's round trip, such as 100 ms
against 150 ms, wrapped around to about 4.29 billion ms, so the callee
was told it had roughly 49 days to respond while the server's own timer
still used the real value. lastRTT was also read without p.lock, which
UpdateMediaRTT holds when it writes it.
Read lastRTT under the lock and keep the caller's timeout when the round
trip is not below it.
Fixes#5151
* rtc: compute the forwarded RPC timeout inline in PerformRpc
Drop the rpcRequestTimeoutMs helper and test the forwarded
ResponseTimeoutMs through PerformRpc instead.
When an Allocate has no REQUESTED-ADDRESS-FAMILY, as from browsers,
pion picks the relay family from the address the client reached. The
default bind address 0.0.0.0 is a dual-stack listener, so a client that
connects over IPv6 gets a udp6 relay, which the IPv4 bind address
cannot open. Every allocation fails with 508 Insufficient Capacity and
the client gets no relay candidate.
Set StrictAddressFamily, which keeps the RFC 8656 default of an IPv4
relay, when no bind address is IPv6. Those nodes could never open an
IPv6 relay, so no allocation that succeeded before changes. With an
IPv6 bind address, pion's default is kept.
Fixes#5131
* protocol update for no agent permission update
* test: expect 401 on TURN auth failure
pion/turn v5.1.3 answers a failed authenticated allocate with 401 Unauthorized instead of 400 Bad Request.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* rtc: do not send lk.rtc. attributes to other participants
Older JS SDKs fire ParticipantAttributesChanged for each new remote
participant that carries a server-set lk.rtc. attribute, and some crash
on it. Drop those attributes from participant updates and the join
response for everyone except the receiver.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* rtc: strip lk.rtc. attributes for every receiver
Also drop them from the receiver's own info and from the room moved
response, so no client sees them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: expect the join request time attribute to stay on the server
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Inbound trunk numbers are compared to the user part of the called URI,
so a sip:, sips: or tel: URI never matches a call. Reject new URI
entries on create and update. Entries already stored on a trunk stay
allowed, so existing trunks can still be updated.
* fix: validate data track handle range in HandleUnpublishDataTrackRequest
HandlePublishDataTrackRequest rejects pub_handle 0 and values above 65535 with INVALID_HANDLE, but HandleUnpublishDataTrackRequest casts the wire uint32 to uint16 without the same check. A client sending pub_handle 65537 wraps to handle 1 and unpublishes its own valid data track instead of receiving an error. This adds the same range validation to the unpublish handler before the uint16 conversion, with the same INVALID_HANDLE reason and message as publish.
* rtc: skip a closing track when a republish reuses its client id
A MediaTrack whose receiver closed stays in the published list until the
receiver close returns, and that close waits on the subscriber down track
flush. A republish of the same client id in that window was queued behind
the dying track and its media was matched to the dying track and dropped.
The client id lookups now skip a track that is not open, and the track
close handler re-sends TrackPublished only for a queued replacement.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* rtc: answer a queued replacement only once no open track holds its cid
Two tracks can hold the same client id while the older one finishes its
close. The older track's close callback answered a request queued behind
the open track, and the media of that request landed on the open track.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* rtc: release a replacement queued behind any cid of the closing track
AddTrack queues a request behind an open track that holds its cid by
signaling or SDP cid, but the close handler released only the entry under
the track's signaling cid. A request queued by SDP cid stayed queued.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
whipService.Create waits for every track listed in
SubscribedParticipantTracks. The errgroup context was discarded and each
goroutine only exited once its track was subscribed, so a track that
never appears (a wrong name, a publisher that left) kept the request and
a polling goroutine per track running forever, long after the caller
had timed out.
Move the wait into waitForSubscribedTracks and have it return when the
request context is done or the participant has closed.
Fixes#5152
FindRTXPayloadType compared the whole fmtp line with "apt=<pt>", so an
RTX codec negotiated as "apt=96;rtx-time=3000" (RFC 4588) was never
found. The down track then stored RTX payload type 0, sent
retransmissions on the primary SSRC and probed with padding even though
RTX was negotiated.
Compare only the apt parameter of the fmtp line.
* fix(rtc): look up pending before published tracks on resume so a moving track is not missed
* fix(rtc): rewrite the opus answer fmtp by key and drop the line when nothing remains
* chore(rtc): note why a track closed between the resume lookups reads as unknown
* test(rtc): keep the parked reader helper beside its only user in room_test
* fix(rtc): match the opus fmtp by exact payload type in the publisher answer
* rtc: disable transceiver reuse for all WebKit clients, incl. iOS
#4474 turned off transceiver reuse on Safari because of an insertable
streams issue that can corrupt E2EE tracks. The check matched only a
browser name of exactly "Safari". The server's user agent parser reports
iPhone Safari as "Mobile Safari", WKWebView as "Mobile Safari
UI/WKWebView", and other iOS browsers as "Chrome Mobile iOS",
"Firefox iOS", "Edge Mobile" or an app name. All of these run on WebKit,
but they kept transceiver reuse.
Match any browser name that contains "safari", and any JS SDK client on
iOS. React Native, Swift and Flutter do not match. isSafari() and audio
RED are not changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* rtc: reuse isSafari() in isWebKit()
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* rtc: use isWebKit() for audio RED support too
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: lukasIO <mail@lukasseiler.de>
* test(rtc): cover mute and track updates while a track moves to published
* fix(rtc): hold pendingTracksLock across both track lookups in mute and track updates
* fix(rtc): update queued replacements before the published track in mute and track updates
* test(rtc): cover the publisher answer while its track moves to published
* fix(rtc): hold pendingTracksLock across both track lookups when configuring the publisher answer
Stores that filter trunks by exact number, such as the Cloud database,
only normalize the called number, so a stored 15550001002 never matches
a call to +15550001002. Normalize numbers on create and update, including
the stored list and the Remove values, so that old rows are fixed on edit.
* agent: stamp the accept and assignment times of a job attempt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* deps: pin protocol with the job accept and assignment times
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* service: stamp a new user's join request time as an attribute
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* service: use the lk.rtc.joinRequestTime name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* deps: pin protocol with the join request time attribute
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* deps: pin protocol main, keep turn and srtp on pion/transport v4
The warp forks of webrtc, dtls and ice are still on pion/transport v4.
Protocol main requires turn 5.1 and srtp 3.1, which moved to v5.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* deps: pin protocol v1.53.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: expect the join request time among a participant's attributes
The server now stamps lk.rtc.joinRequestTime on a new standard participant, so
other participants see it beside the token's attributes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* deps: bump mediatransportutil to the transport-v4 tip
Picks up configurable external-IP discovery: external_ip_retries,
external_ip_retry_interval, external_ip_stun_timeout and
external_ip_validation_timeout on RTCConfig, plus the WithStunPingTimeout and
WithValidationTimeout options. Defaults unchanged, no code change here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* deps: move mediatransportutil pin to include the NAT mapping fix
Advances the transport-v4 pin to the tip that also carries the per-interface
NAT mapping timeout fix, so the configurable external-IP timeouts apply to both
the boot-time and mapping discovery phases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
applySettings releases settingsLock before it computes the video layers,
then reads t.settings.Quality, Width, Height and Fps without the lock.
UpdateSubscriberSettings replaces t.settings under the lock, and the
debouncer and UpdateVideoLayer run applySettings on other goroutines, so
the reads race with the write. go test -race reports it on the pointer
and on the fields of the newly cloned settings.
Take the settings pointer while holding the lock and compute the layers
from that snapshot. UpdateSubscriberSettings always stores a fresh clone
and never mutates it, so the snapshot is safe to read after unlocking,
and all four values come from the same update.
* rtc: match publisher m-sections by mid, not by position
A client that stops a transceiver lets the next one recycle its m-line
(JSEP 5.2.2), so a new m-section can come before negotiated ones.
GetUnmatchMediaForOffer stopped at the last answered mid and missed it,
so its codec preferences were never applied.
Bump the protocol so clients know they can stop transceivers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Update protocol_version.go
* Remove comments from GetUnmatchMediaForOffer function
Removed comments explaining the GetUnmatchMediaForOffer function.
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ValidVideoLayers checks that each layer's quality is in range but lets a
quality repeat. GetQualityForDimension uses the index into the sorted
layer sizes as the quality, so a list such as LOW, LOW, MEDIUM, HIGH maps
the largest size to index 3, which is OFF, and a subscriber asking for
that size receives no video.
Reject an AddTrack whose layer list repeats a quality. Each list (the
request's and each simulcast codec's) is checked on its own, so the same
qualities on the request and a codec are still accepted. With only three
qualities this also bounds a list to three layers.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The test client pc gathered host candidates on every interface. On a
host with a network content filter, a STUN binding request can block
in sendto forever. The write runs inside the ICE agent taskloop, so
SetRemoteDescription and the next CreateOffer wait on that loop and
the package hits its timeout.
The test checks only SDP, so the client pc now gathers no host
candidates and neither agent sends a connectivity check.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* rtc: do not advertise a subscribed track the subscriber cannot decode
In single peer connection mode a subscriber offers a recvonly section with
its own codecs. If the published track uses a codec the subscriber did not
offer, DownTrack.Bind finds no match but returns nil, because returning an
error aborts the whole transport. pion then keeps the sender, so the answer
still carries the track msid and ssrc. The subscriber sees a short-lived
TrackSubscribed before the bind failure removes the track again.
Park such a sender before building the answer: drop its track so the
section answers inactive, then report the codec failure out of band so the
subscription manager unsubscribes without reentering negotiation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* rtc: report codec mismatch via Handler, not the downtrack
Route the parked sender's failure through a new Handler method into the
participant, instead of reaching from transport into the downtrack. The
participant unsubscribes and reports the codec error. Keeps transport
free of the sfu package.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* rtc: use CodecsFromMediaDescription for the offered-codec check
Reuse the protocol sdp helper instead of hand-parsing rtpmap lines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* rtc: reword codec-suppression comments
Describe it as a subscription, drop the stale prototype label.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* rtc: check every bindable codec before suppressing a sender
The check before the answer looked only at the primary codec. A subscriber
that offers a later upstream codec, for example Opus for a RED-first track,
lost a track that Bind can bind.
DownTrack.BindableCodecs returns the codecs Bind can pick, and both Bind and
the check use it. It also fixes the encrypted RED/Opus rule in Bind: the old
condition was never true, so an encrypted RED track could bind as Opus.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* rtc: keep published encrypted codecs bindable, record codec rejections
The encrypted RED/Opus rule cut every RED/Opus pair to the primary codec.
That also dropped a second codec the publisher sends on its own stream.
WrappedReceiver now decides which codecs bind: for an encrypted source,
only its published codecs, never the RED or Opus alternative it adds.
A sender parked before the answer now ends its subscription through the
same path as a bind error, so it records OnTrackSubscribeFailed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* rtc: log the attributes of every data channel
Both transports log "data channel open" at info with the channel's
label, id, ordered, maxRetransmits, maxPacketLifeTime, protocol and
negotiated, through one helper.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* paul feedback
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* sfu: ignore a nil publisher sender report in the forwarder
A stream restart replaces the receiver stats between the sender report
callback and its read, so the report can be nil. SetRefSenderReport now
keeps the previous report instead of reading the nil one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* dynacast: skip a codec regression when the target codec has no quality
HandleCodecRegression looked up the target codec after it released the
lock. If the manager closed in between, or the target mime type was
unknown, the lookup returned nil and RegressTo called Restart or Replace on
it. The target is now looked up with the source, and a missing one stops
the regression.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* service: create the internal record for a stored room that has none
RedisStore.LoadRoom returns a nil RoomInternal without an error when the
room exists but its internal field does not, for example after a delete
races a store. CreateRoom then wrote egress, agent, playout delay or sync
settings into the nil record. It now starts from an empty record, which
the store call writes back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* sfu: drop a dependency descriptor whose chains do not match the structure
The parser takes a new structure from any packet, but DDForwarder keeps
the structure of the last packet it forwarded. When the publisher attached
a structure with fewer chains to a packet this downtrack dropped, the next
descriptor had fewer chain diffs than the forwarder's structure, and the
writer indexed past them. The forwarder now drops such a packet, and the
writer returns an error instead of indexing past the chain diffs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* deps: bump mediatransportutil for the empty VP9 payload fix
A VP9 packet without a dependency descriptor and with a 1-byte payload such
as 0x08 parses to an empty VP9 payload, and IsVP9KeyFrame read its first
byte. mediatransportutil#106 returns false for an empty payload. This pins
the transport-v4 commit that carries it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* sfu: keep the template test comment with its test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* dynacast: create the target quality only for a valid regression
A target quality starts a timer that notifies the publisher. Look it up
only after the source codec is found, so a rejected regression creates
nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* service: restore the internal defaults for a room that lost them
A stored room without an internal record now gets the configured playout
delay and sync streams defaults. Its own settings, such as the empty
timeout and codecs, stay as stored.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
An unlabeled data message sent to a participant without an unlabeled
channel is converted to a UserPacket and written to the reliable
channel. That write failed while the channel was still opening, and the
message was lost. The participant now builds the UserPacket and sends
it through SendDataMessage, which holds unsequenced reliable data until
the channel is writable. The transport keeps only the raw path.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* rtc: broadcast an unlabeled data message from a source identity
BroadcastDataMessageForRoomFrom takes the source ID and identity instead
of a LocalParticipant, so a caller can stamp the sender of a message
whose source is not on this node. BroadcastDataMessageForRoom keeps its
signature and calls it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* rtc: say what sourceID does in the BroadcastDataMessageForRoomFrom comment
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Relayed unlabeled data arrives with no local source participant, and
the sender identity lookup dereferenced it.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A client sets SimulcastCodec.VideoLayerMode on AddTrack. Any value other
than MODE_UNUSED is used as-is, so a forged out-of-range value flows into
the forwarder. Reject it in ValidVideoLayers, alongside the quality check.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>