fix(media): contain connection failures and preserve stereo preferences
An individually rejected ICE candidate could abort the App's local media bridge, an SFU publication or a Browser encoding probe. Separately, a retiring native downstream edge could propagate its closed transport into the shared source and stop capture. This phase keeps those failures at their existing resource owners, fences late bridge signaling after disposal, and corrects native Opus receive preferences. Existing candidate handling is reused across sibling paths. No route policy, retry budget, protocol version, configuration or UI is added. Browser ingress stereo normalization remains necessary for already-published Apps. Validation: `npm run check` (74 files, 1081 tests, type-check and Web build), `npm run check:go` (including Windows native compilation), documentation and repository hygiene passed. Windows Chrome H264 and VP8 gates passed candidate fault injection, resize/fullscreen/minimize transitions, live/paused settings, source replacement and re-sharing; Auto whole-display capture also passed. A real Chrome codec-probe comparison reproduces the old false H264 rejection and verifies the correction. Regression tests reproduce the downstream retirement and receive-preference defects. Linux CI owns the Go race check because the local Windows environment lacks cgo. Reported game/device failures remain unconfirmed without matched diagnostics. These fixes do not claim to resolve every share-start or game-entry failure. Interaction/voice/HDR work remains outside this release; its next steps are recorded in the existing TODO ledger. ## Release notes ### 中文 这是一次分享稳定性修复更新,重点避免局部连接错误影响整场分享,并修正原生音频接收协商。 - 修复单条 ICE 候选被拒绝时可能导致开播失败、SFU 发布中断,或自动编码检测误判的问题;连接会继续使用其他有效候选。 - 修复原生下游连接关闭时可能误停整场分享的竞态,并阻止已关闭的本机媒体连接继续发送过期信令。 - 修正原生接收端的 Opus 立体声偏好,首次连接和重新协商均会明确请求立体声。 保持现有版本兼容,无需修改配置。具体游戏、显卡或网络环境中的异常仍需结合诊断记录确认。 ### English This maintenance update improves sharing stability by containing local connection failures and correcting native audio receive negotiation. - Fixed cases where a rejected ICE candidate could prevent sharing, interrupt an SFU publication, or incorrectly rule out H264 during automatic codec detection. Other valid candidates remain usable. - Fixed a race where closing a native downstream connection could stop the entire share, and prevented disposed local media connections from sending stale signaling. - Corrected native Opus receive preferences to request stereo on both initial connection and renegotiation. Existing version compatibility is preserved; no configuration changes are required. Failures specific to a game, GPU or network still need matching diagnostics to establish their cause.
This commit is contained in:
16 files changed
+362
-22
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Project Memory
|
||||
|
||||
Last updated: 2026-09-21
|
||||
Last updated: 2026-09-24
|
||||
|
||||
Piik is private, low-latency screen sharing for one Host and up to 20
|
||||
authenticated friends. The current product surface is Web Host, Web Viewer, and
|
||||
@@ -28,7 +28,8 @@ Native parents reuse suitable encoded outputs and derive a missing lower output
|
||||
only for direct-child demand. Source-owned groups retain the highest needed
|
||||
output and lower fallbacks, while each child receives its selected output.
|
||||
Native Host SFU publication reuses the encoded source directly; its loopback
|
||||
Browser edge owns preview only. Eligible Browser capture and relay now share
|
||||
Browser stream supplies local preview and Browser quality candidates. Eligible
|
||||
Browser capture and relay now share
|
||||
independent local WebRTC producers for compatible direct children under ADR-0014;
|
||||
unsupported APIs and failed pooling retain ordinary senders. Windows, macOS and
|
||||
Linux producers share one encoded-frame
|
||||
|
||||
@@ -186,6 +186,8 @@ starts one shared decode/scale/encode group, while healthy forwarding retains
|
||||
the original input without starting a decoder. The group produces the highest
|
||||
needed output and its lower fallbacks and stops unused upper outputs. Each child
|
||||
receives only its selected output. The forwarding SFU adds no server transcoder.
|
||||
Downstream attachment failure or retirement ends only that edge, never its
|
||||
shared source or healthy siblings.
|
||||
|
||||
Native capture uses the same screen-share output construction within Host
|
||||
ceilings. Its source, source clock and higher outputs remain independent of
|
||||
@@ -234,8 +236,10 @@ application-defined whole-room lowest-common-denominator target.
|
||||
## Screen Audio
|
||||
|
||||
Display audio uses `contentHint = "music"`. The live sender ceilings are 64,
|
||||
128, and 192 kbps with 128 kbps default. Peer answers request Opus stereo and a
|
||||
192 kbps receive ceiling. The SFU publication uses stereo, DTX disabled, and RED
|
||||
128, and 192 kbps with 128 kbps default. Browser and Native Peer answers request
|
||||
Opus stereo and a 192 kbps receive ceiling, including renegotiation. These are
|
||||
local receive preferences, independent of the offer's receive preferences.
|
||||
The SFU publication uses stereo, DTX disabled, and RED
|
||||
disabled; this accepts less burst-loss resilience in exchange for bounded
|
||||
publisher traffic. The Browser and source still decide whether an audio track
|
||||
exists and what is actually delivered.
|
||||
|
||||
@@ -146,6 +146,8 @@ An individually rejected ICE candidate is disposable input. Browser
|
||||
remaining queue or SDP answer; missing-description/lifecycle errors remain
|
||||
visible. Native input follows the same boundary. Actual transport state and the
|
||||
route operation's deadline own failure, not one rejected candidate.
|
||||
This includes SFU and local Browser/App media connections and codec probes;
|
||||
their different consumers do not change candidate rejection semantics.
|
||||
|
||||
## SFU Fallback
|
||||
|
||||
|
||||
+30
-1
@@ -1,6 +1,6 @@
|
||||
# Current TODO Ledger
|
||||
|
||||
Last reviewed: 2026-09-23
|
||||
Last reviewed: 2026-09-24
|
||||
|
||||
Only **Now** is executable. Product modules own behavior; Git/PRs own completed
|
||||
history. A parked idea is not implementation authority.
|
||||
@@ -28,6 +28,17 @@ history. A parked idea is not implementation authority.
|
||||
can resume, and stopping media keeps the opted-in interaction session. Do not
|
||||
add visit-triggered creation or a separate pre-share room-creation entry.
|
||||
This experiment does not authorize integration or publication.
|
||||
- [ ] **Assess room voice and media fidelity alongside interaction.** Compare
|
||||
mature room-audio designs before selecting a small first scope: explicit mic
|
||||
consent, mute/device/output controls, speaker authority and disconnect cleanup.
|
||||
Keep room membership, voice publication and the Host's shared picture distinct;
|
||||
do not send audio through chat signaling or reverse the current screen tree.
|
||||
Verify actual left/right separation through Browser, Native and SFU paths;
|
||||
receive-side stereo negotiation alone does not establish audible fidelity.
|
||||
Assess HDR capture to correct SDR output for ordinary viewers before full HDR,
|
||||
with issue [#420](https://github.com/TNTcraftHIM/Piik/issues/420) as the reported
|
||||
case. Multi-speaker routing, full HDR and surround sound remain design decisions,
|
||||
not additions authorized for this maintenance release.
|
||||
|
||||
## Awaiting Device Or Reporter Evidence
|
||||
|
||||
@@ -86,6 +97,19 @@ history. A parked idea is not implementation authority.
|
||||
local permission and recovery checks do not establish the reporters' causes.
|
||||
Manual H264 returning immediately to idle also needs reporter diagnostics;
|
||||
bounded selection past an unusable hardware encoder is locally verified.
|
||||
A separate v1.6.1 App/Chrome report lists Window/Screen sources, then waits
|
||||
and returns to idle with a share-start error after selection. Its codec is
|
||||
unknown; similarity to the H264 symptom does not establish the same cause.
|
||||
Candidate rejection no longer aborts the local media bridge; validate on the
|
||||
affected machine before attributing its startup failure to that defect.
|
||||
- [ ] **Share ends after entering a game.** Screen sharing reportedly works
|
||||
until entering a game freezes the picture, followed seconds later by share
|
||||
termination. Version, capture path, codec and matched diagnostics are unknown.
|
||||
Locate the first capture/output, preview-bridge, control or authority failure;
|
||||
distinguish ordinary source silence/resize from target replacement, exclusive
|
||||
fullscreen, display-mode change and device loss. A reproduced downstream
|
||||
retirement race no longer stops the shared source. Controlled-window checks
|
||||
do not reproduce the reporting game/device environment.
|
||||
- [ ] **Windows 32-bit candidate acceptance.** Verify the isolated
|
||||
`spike/windows-x86-capture` candidate's launch, capture/audio, memory pressure,
|
||||
source replacement and update links on a 32-bit Windows device. WOW64
|
||||
@@ -148,6 +172,11 @@ not establish better connection success or speed; this note adds no retry policy
|
||||
When related behavior changes, compare the Browser/Native recovery budget;
|
||||
preserve Native bridge versus network failure distinctions when sharing code.
|
||||
No new revision namespace, failure-state mirror or topology queue by default.
|
||||
Native Host loopback media failure currently ends the share, although Native
|
||||
publication has its own source. The loopback stream also supplies Browser
|
||||
quality candidates and gates quality/SFU updates; simply ignoring its failure
|
||||
leaves dead consumers. Review those owners and the track-ended handler before
|
||||
changing recovery. This is not an established cause of the game-entry report.
|
||||
5. **Storage fault recovery.** Choose and verify a damaged-disk/COMMIT/ROLLBACK
|
||||
recovery policy before adding catch-and-continue or retries. This failure
|
||||
boundary remains unestablished after ordinary persistence checks.
|
||||
|
||||
@@ -222,6 +222,25 @@ func (receiver *Receiver) createAnswer() (webrtc.SessionDescription, error) {
|
||||
return webrtc.SessionDescription{}, errors.New("native media receiver is closed")
|
||||
}
|
||||
receiver.mu.Unlock()
|
||||
// Opus stereo is a local receive preference, not a property inherited from
|
||||
// the offer. Apply it on every answer, including same-source renegotiation.
|
||||
for _, transceiver := range receiver.connection.GetTransceivers() {
|
||||
if transceiver.Kind() != webrtc.RTPCodecTypeAudio || transceiver.Receiver() == nil ||
|
||||
(transceiver.Direction() != webrtc.RTPTransceiverDirectionRecvonly &&
|
||||
transceiver.Direction() != webrtc.RTPTransceiverDirectionSendrecv) {
|
||||
continue
|
||||
}
|
||||
for _, codec := range transceiver.Receiver().GetParameters().Codecs {
|
||||
if !strings.EqualFold(codec.MimeType, webrtc.MimeTypeOpus) {
|
||||
continue
|
||||
}
|
||||
codec.SDPFmtpLine = opusSDPFmtpLine + ";stereo=1;maxaveragebitrate=192000"
|
||||
if err := transceiver.SetCodecPreferences([]webrtc.RTPCodecParameters{codec}); err != nil {
|
||||
return webrtc.SessionDescription{}, err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
answer, err := receiver.connection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
return webrtc.SessionDescription{}, err
|
||||
|
||||
@@ -8,9 +8,93 @@ import (
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/sfu"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/pion/sdp/v3"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
func TestReceiverAdvertisesLocalStereoPreference(t *testing.T) {
|
||||
for _, remotePreference := range []string{"", ";stereo=0", ";stereo=1"} {
|
||||
t.Run(remotePreference, func(t *testing.T) {
|
||||
engine, err := NewEngine(EngineOptions{BindAddress: "127.0.0.1:0", IncludeLoopback: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = engine.Close() })
|
||||
// Independent upstream capabilities: local registration must not make
|
||||
// the fixture advertise the receive preference we are checking.
|
||||
upstream, err := webrtc.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = upstream.Close() })
|
||||
for _, kind := range []webrtc.RTPCodecType{webrtc.RTPCodecTypeVideo, webrtc.RTPCodecTypeAudio} {
|
||||
transceiver, addErr := upstream.AddTransceiverFromKind(kind, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly})
|
||||
if addErr != nil {
|
||||
t.Fatal(addErr)
|
||||
}
|
||||
if kind == webrtc.RTPCodecTypeAudio {
|
||||
for _, codec := range transceiver.Sender().GetParameters().Codecs {
|
||||
if codec.MimeType != webrtc.MimeTypeOpus {
|
||||
continue
|
||||
}
|
||||
codec.SDPFmtpLine = "minptime=10;useinbandfec=1" + remotePreference
|
||||
err = transceiver.SetCodecPreferences([]webrtc.RTPCodecParameters{codec})
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var receiver *Receiver
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
offer, err := upstream.CreateOffer(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = upstream.SetLocalDescription(offer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var answer webrtc.SessionDescription
|
||||
if receiver == nil {
|
||||
receiver, answer, err = engine.NewReceiver(ReceiverOptions{Offer: offer, EdgeCapacity: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = receiver.Close() })
|
||||
} else {
|
||||
var reused bool
|
||||
answer, reused, err = receiver.Renegotiate(offer, nil)
|
||||
if err != nil || !reused {
|
||||
t.Fatalf("renegotiate: reused=%v, error=%v", reused, err)
|
||||
}
|
||||
}
|
||||
var parsed sdp.SessionDescription
|
||||
if err = parsed.UnmarshalString(answer.SDP); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, media := range parsed.MediaDescriptions {
|
||||
if media.MediaName.Media != "audio" {
|
||||
continue
|
||||
}
|
||||
for _, attribute := range media.Attributes {
|
||||
if attribute.Key == "fmtp" && strings.Contains(attribute.Value, "stereo=1") && strings.Contains(attribute.Value, "maxaveragebitrate=192000") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("answer %d lost local stereo preference", attempt)
|
||||
}
|
||||
if err = upstream.SetRemoteDescription(answer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiverCodecMatchesTheSingleNegotiatedAnswer(t *testing.T) {
|
||||
for _, preferVP8 := range []bool{false, true} {
|
||||
engine, err := NewEngine(EngineOptions{BindAddress: "127.0.0.1:0", IncludeLoopback: true})
|
||||
|
||||
@@ -133,7 +133,10 @@ func (source *Source) beginFrame(timestamp time.Duration, at time.Time) (OutputP
|
||||
continue
|
||||
}
|
||||
if err := edge.transport.SetConnected(); err != nil {
|
||||
return OutputPlan{}, err
|
||||
// Retirement can race the connected snapshot. An attachment failure
|
||||
// belongs to this consumer, not the shared capture and its siblings.
|
||||
unavailable = append(unavailable, edge)
|
||||
continue
|
||||
}
|
||||
if source.unavailableDemand(edge) {
|
||||
unavailable = append(unavailable, edge)
|
||||
|
||||
@@ -2,16 +2,81 @@ package mediaedge
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TNTcraftHIM/Piik/internal/media/encoded"
|
||||
"github.com/TNTcraftHIM/Piik/internal/media/forwarding"
|
||||
"github.com/livekit/livekit-server/pkg/sfu"
|
||||
"github.com/livekit/livekit-server/pkg/sfu/streamtracker"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestSourceFrameSurvivesRetiringEdge(t *testing.T) {
|
||||
for _, layers := range []int{1, 2} {
|
||||
t.Run(fmt.Sprint(layers), func(t *testing.T) {
|
||||
engine, err := NewEngine(EngineOptions{BindAddress: "127.0.0.1:0", IncludeLoopback: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = engine.Close() })
|
||||
source, err := engine.NewSource("vp8", 2, layers, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = source.Close() })
|
||||
if err = source.ConfigureOutputs([]uint32{300_000, 1_200_000}[:layers]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
healthy, _, packets := connectedReceiver(t, engine, source, "healthy")
|
||||
observed, _, _ := connectedReceiver(t, engine, source, "retiring-observation")
|
||||
retired, err := forwarding.NewTransport(forwarding.TransportOptions{
|
||||
Source: source.media.Source, ConnectionID: "retiring", InitialBitrate: 1_200_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = retired.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Model the interleaving after observing connected but before attachment:
|
||||
// the transport has closed, while the source still holds the edge snapshot.
|
||||
// Borrow an independently live PC for that observation; no running edge is mutated.
|
||||
stale := &Edge{engine: engine, source: source, connection: observed.connection, transport: retired}
|
||||
source.mu.Lock()
|
||||
delete(source.edges, observed)
|
||||
source.edges[stale] = false
|
||||
source.mu.Unlock()
|
||||
for i := 0; i < 3; i++ {
|
||||
pts := time.Duration(i) * time.Second / 30
|
||||
if _, err = source.BeginFrame(pts); err != nil {
|
||||
t.Fatalf("edge close stopped source: %v", err)
|
||||
}
|
||||
if err = source.WriteVideo(layers-1, encoded.Frame{Data: sfu.VP8KeyFrame8x8, PTS: pts, Duration: time.Second / 30, Recovery: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !stale.closed || healthy.State() != webrtc.PeerConnectionStateConnected {
|
||||
t.Fatal("retirement did not isolate the failed edge")
|
||||
}
|
||||
select {
|
||||
case <-packets:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("healthy sibling lost media")
|
||||
}
|
||||
if err = source.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = source.BeginFrame(time.Second); !errors.Is(err, io.ErrClosedPipe) {
|
||||
t.Fatalf("actual source closure was hidden: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceFormatAdaptationPreservesLiveLayers(t *testing.T) {
|
||||
// A source needs no socket or codec process to exercise its RTP state.
|
||||
source, err := (&Engine{}).NewSource("vp8", 2, 2, nil)
|
||||
|
||||
@@ -186,11 +186,20 @@ async function selectNativeSource(
|
||||
kind: "window" | "display",
|
||||
title?: string,
|
||||
): Promise<void> {
|
||||
await evaluate<void>(
|
||||
cdp,
|
||||
page,
|
||||
`document.querySelector('button[data-source-tab="${kind}"]')?.click()`,
|
||||
Date.now() + 5_000,
|
||||
await waitForValue(
|
||||
(deadline) => evaluate<boolean>(
|
||||
cdp,
|
||||
page,
|
||||
`(() => {
|
||||
const tab = document.querySelector('button[data-source-tab="${kind}"]');
|
||||
if (!tab || tab.disabled) return false;
|
||||
tab.click();
|
||||
return true;
|
||||
})()`,
|
||||
deadline,
|
||||
),
|
||||
Boolean,
|
||||
15_000,
|
||||
);
|
||||
const selector = kind === "display"
|
||||
? "button[data-native-source^='display:']"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createOpaqueId } from "../lib/opaque-id";
|
||||
import { debugError, debugEvent } from "../lib/debug";
|
||||
import { debugRtcFailure, debugRtcStats, debugTrack, observeDebugConnection } from "../lib/debug-webrtc";
|
||||
import { maxEncodedVideoFrames } from "../webrtc/stats";
|
||||
import { addRemoteIceCandidate } from "../webrtc/nat-prediction";
|
||||
import { encodedStreams } from "./browser-encoding-output";
|
||||
import {
|
||||
applyVideoCaptureProfile, cloneSenderVideoTrack, configureVideoSender,
|
||||
@@ -95,12 +96,12 @@ export class BrowserEncodingProducer {
|
||||
from.onicecandidate = ({ candidate }) => {
|
||||
if (!candidate || this.disposed) return;
|
||||
if (!to.remoteDescription) pending.push(candidate);
|
||||
else void to.addIceCandidate(candidate).catch(this.fail);
|
||||
else void addRemoteIceCandidate(to, candidate).catch(this.fail);
|
||||
};
|
||||
return async () => {
|
||||
for (const candidate of pending.splice(0)) {
|
||||
this.checkAlive();
|
||||
await to.addIceCandidate(candidate);
|
||||
await addRemoteIceCandidate(to, candidate);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createOpaqueId } from "../lib/opaque-id";
|
||||
import { browserDebugEnabled, debugError, debugEvent } from "../lib/debug";
|
||||
import { observeDebugConnection } from "../lib/debug-webrtc";
|
||||
import type { ConnectionMetrics } from "../types";
|
||||
import { addRemoteIceCandidate, type SignalCandidate } from "../webrtc/nat-prediction";
|
||||
import {
|
||||
collectConnectionMetrics,
|
||||
decodedVideoFrames,
|
||||
@@ -35,7 +36,7 @@ export class NativeMediaBridge {
|
||||
readonly connectionId = createOpaqueId();
|
||||
|
||||
private readonly peer = new RTCPeerConnection();
|
||||
private readonly pendingCandidates: Array<RTCIceCandidateInit | null> = [];
|
||||
private readonly pendingCandidates: SignalCandidate[] = [];
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
private remoteDescriptionSet = false;
|
||||
private ready = false;
|
||||
@@ -110,6 +111,7 @@ export class NativeMediaBridge {
|
||||
debugEvent("native-bridge", "ice-gathering-state", { state: this.peer.iceGatheringState });
|
||||
});
|
||||
this.peer.addEventListener("icecandidate", (event) => {
|
||||
if (this.disposed) return;
|
||||
void this.control.acceptSignal(
|
||||
this.shareId,
|
||||
this.connectionId,
|
||||
@@ -148,8 +150,7 @@ export class NativeMediaBridge {
|
||||
}
|
||||
return;
|
||||
}
|
||||
void this.peer
|
||||
.addIceCandidate(event.candidate)
|
||||
void addRemoteIceCandidate(this.peer, event.candidate)
|
||||
.catch(() => this.fail("candidate-apply"));
|
||||
} else if (
|
||||
event.type === "edge-state" &&
|
||||
@@ -165,16 +166,18 @@ export class NativeMediaBridge {
|
||||
this.connectionId,
|
||||
this.sourceConnectionId,
|
||||
);
|
||||
if (this.disposed) return await started;
|
||||
await this.peer.setRemoteDescription(offer);
|
||||
this.remoteDescriptionSet = true;
|
||||
for (const candidate of this.pendingCandidates.splice(0)) {
|
||||
await this.peer.addIceCandidate(candidate);
|
||||
await addRemoteIceCandidate(this.peer, candidate);
|
||||
}
|
||||
const answer = await this.peer.createAnswer();
|
||||
if (!answer.sdp) {
|
||||
throw new Error("Native media bridge produced no SDP answer");
|
||||
}
|
||||
await this.peer.setLocalDescription(answer);
|
||||
if (this.disposed) return await started;
|
||||
await this.control.acceptSignal(
|
||||
this.shareId,
|
||||
this.connectionId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { observeDebugConnection } from "../lib/debug-webrtc";
|
||||
import { addRemoteIceCandidate } from "../webrtc/nat-prediction";
|
||||
import type {
|
||||
ServerMessage,
|
||||
SfuMedia,
|
||||
@@ -20,7 +21,7 @@ interface SfuPeerEvents {
|
||||
export class SfuPeer {
|
||||
readonly pc = new RTCPeerConnection({ iceServers: [] });
|
||||
private localCandidates: NonNullable<SfuSignalMessage["candidate"]>[] = [];
|
||||
private remoteCandidates: RTCIceCandidateInit[] = [];
|
||||
private remoteCandidates: NonNullable<SfuSignalMessage["candidate"]>[] = [];
|
||||
private descriptionSent = false;
|
||||
private pendingDescription: Pick<
|
||||
SfuSignalMessage,
|
||||
@@ -132,10 +133,10 @@ export class SfuPeer {
|
||||
} else if (message.kind === "description" && message.description) {
|
||||
await onDescription(message.description);
|
||||
for (const candidate of this.remoteCandidates.splice(0))
|
||||
await this.pc.addIceCandidate(candidate);
|
||||
await addRemoteIceCandidate(this.pc, candidate);
|
||||
} else if (message.kind === "candidate" && message.candidate) {
|
||||
if (this.pc.remoteDescription)
|
||||
await this.pc.addIceCandidate(message.candidate);
|
||||
await addRemoteIceCandidate(this.pc, message.candidate);
|
||||
else if (this.remoteCandidates.length < 64)
|
||||
this.remoteCandidates.push(message.candidate);
|
||||
else throw new Error("Too many SFU ICE candidates");
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
applyH264ProbeCodec,
|
||||
type BrowserVideoCodec,
|
||||
} from "./video-codec";
|
||||
import { addRemoteIceCandidate } from "./nat-prediction";
|
||||
|
||||
const PREFLIGHT_DEADLINE_MS = 4_000;
|
||||
const PREFLIGHT_POLL_MS = 100;
|
||||
@@ -304,7 +305,7 @@ async function runH264Probe(
|
||||
candidate: RTCIceCandidate,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await target.addIceCandidate(candidate);
|
||||
await addRemoteIceCandidate(target, candidate);
|
||||
} catch {
|
||||
iceFailed = true;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ class Connection {
|
||||
static instances: Connection[] = [];
|
||||
connectionState = "connecting";
|
||||
onconnectionstatechange: (() => void) | null = null;
|
||||
onicecandidate: ((event: { candidate: RTCIceCandidate }) => void) | null = null;
|
||||
readonly addIceCandidate = vi.fn(async (_candidate: RTCIceCandidateInit) => undefined);
|
||||
localDescription: RTCSessionDescriptionInit | null = null;
|
||||
remoteDescription: RTCSessionDescriptionInit | null = null;
|
||||
readonly close = vi.fn(() => { this.connectionState = "closed"; });
|
||||
@@ -64,6 +66,25 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("Browser encoding producer startup", () => {
|
||||
it.each(["queued", "active"])("keeps the producer after a %s candidate rejection", async (phase) => {
|
||||
const { producer, failed } = createProducer();
|
||||
const starting = producer.start();
|
||||
const [send, receive] = Connection.instances;
|
||||
const candidate = { candidate: "candidate:1 1 udp 1 127.0.0.1 9000 typ host" } as RTCIceCandidate;
|
||||
receive!.addIceCandidate.mockRejectedValueOnce(new DOMException("Rejected candidate", "OperationError"));
|
||||
if (phase === "queued") send!.onicecandidate?.({ candidate });
|
||||
await starting;
|
||||
for (const connection of Connection.instances) connection.state("connected");
|
||||
if (phase === "active") send!.onicecandidate?.({ candidate });
|
||||
const valid = { candidate: "candidate:2 1 udp 1 127.0.0.1 9001 typ host" } as RTCIceCandidate;
|
||||
send!.onicecandidate?.({ candidate: valid });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(receive!.addIceCandidate).toHaveBeenCalledWith(valid);
|
||||
expect(failed).not.toHaveBeenCalled();
|
||||
expect(Connection.instances.every(connection => connection.close.mock.calls.length === 0)).toBe(true);
|
||||
expect(producer.track?.readyState).toBe("live");
|
||||
});
|
||||
|
||||
it.each([false, true])("retires a stuck local transport once (one side connected: %s)", async (oneSide) => {
|
||||
const { producer, source, failed } = createProducer();
|
||||
await producer.start();
|
||||
|
||||
@@ -39,6 +39,7 @@ class FakePeerConnection extends EventTarget {
|
||||
iceConnectionState: RTCIceConnectionState = "new";
|
||||
iceGatheringState: RTCIceGatheringState = "new";
|
||||
readonly getReceivers = vi.fn<() => RTCRtpReceiver[]>(() => []);
|
||||
readonly getSenders = vi.fn<() => RTCRtpSender[]>(() => []);
|
||||
readonly getStats = vi.fn(async () => new Map());
|
||||
readonly addIceCandidate = vi.fn(async () => undefined);
|
||||
readonly setRemoteDescription = vi.fn(async () => undefined);
|
||||
@@ -46,7 +47,7 @@ class FakePeerConnection extends EventTarget {
|
||||
type: "answer" as const,
|
||||
sdp: "v=0\r\n",
|
||||
}));
|
||||
readonly setLocalDescription = vi.fn(async () => undefined);
|
||||
readonly setLocalDescription = vi.fn(async (): Promise<void> => undefined);
|
||||
readonly close = vi.fn(() => {
|
||||
this.connectionState = "closed";
|
||||
});
|
||||
@@ -130,6 +131,57 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("native media bridge", () => {
|
||||
it.each(["queued", "active"])("keeps media alive after a %s candidate is rejected", async (phase) => {
|
||||
const current = fixture();
|
||||
const peer = current.peer();
|
||||
if (phase === "queued") {
|
||||
peer.addIceCandidate.mockRejectedValueOnce(new DOMException("Rejected candidate", "OperationError"));
|
||||
}
|
||||
const starting = current.bridge.start();
|
||||
// Register a rejection observer before allowing negotiation to run.
|
||||
const result = starting.catch((error: unknown) => error);
|
||||
if (phase === "queued") {
|
||||
current.emit({
|
||||
version: 9, type: "edge-candidate", shareId: "share_123456",
|
||||
connectionId: current.bridge.connectionId,
|
||||
candidate: { candidate: "candidate:2 1 udp 1 127.0.0.1 10 typ host" },
|
||||
});
|
||||
}
|
||||
await vi.waitFor(() => expect(peer.setLocalDescription).toHaveBeenCalledOnce());
|
||||
const track = { kind: "video", stop: vi.fn() } as unknown as MediaStreamTrack;
|
||||
peer.emitTrack(track);
|
||||
peer.setState("connected");
|
||||
await expect(result).resolves.toBe(current.bridge.stream);
|
||||
if (phase === "active") {
|
||||
peer.addIceCandidate.mockRejectedValueOnce(new DOMException("Rejected candidate", "OperationError"));
|
||||
current.emit({
|
||||
version: 9, type: "edge-candidate", shareId: "share_123456",
|
||||
connectionId: current.bridge.connectionId,
|
||||
candidate: { candidate: "candidate:2 1 udp 1 127.0.0.1 10 typ host" },
|
||||
});
|
||||
}
|
||||
current.emit({
|
||||
version: 9, type: "edge-candidate", shareId: "share_123456",
|
||||
connectionId: current.bridge.connectionId, candidate: null,
|
||||
});
|
||||
await vi.waitFor(() => expect(debugError).toHaveBeenCalledWith(
|
||||
"webrtc", "remote-candidate-rejected", expect.any(DOMException), { origin: "ordinary" },
|
||||
));
|
||||
expect(peer.addIceCandidate).toHaveBeenCalledWith(null);
|
||||
expect(peer.close).not.toHaveBeenCalled();
|
||||
expect(track.stop).not.toHaveBeenCalled();
|
||||
expect(current.onFailed).not.toHaveBeenCalled();
|
||||
current.bridge.dispose();
|
||||
});
|
||||
|
||||
it("still rejects invalid negotiation state when applying a candidate", async () => {
|
||||
const current = fixture();
|
||||
current.peer().addIceCandidate.mockRejectedValueOnce(new DOMException("No description", "InvalidStateError"));
|
||||
await expect(current.bridge.start()).rejects.toBeInstanceOf(NativeMediaBridgeError);
|
||||
expect(current.peer().close).toHaveBeenCalledOnce();
|
||||
expect(current.control.closeEdge).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("orders an early native candidate after the remote offer", async () => {
|
||||
const current = fixture();
|
||||
const starting = current.bridge.start();
|
||||
@@ -296,4 +348,28 @@ describe("native media bridge", () => {
|
||||
expect(current.peer().close).toHaveBeenCalledOnce();
|
||||
expect(current.control.closeEdge).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(["prepare", "answer"])("does not signal after disposal during %s", async (stage) => {
|
||||
const current = fixture();
|
||||
let complete!: () => void;
|
||||
const pending = new Promise<void>(resolve => { complete = resolve; });
|
||||
if (stage === "prepare") {
|
||||
vi.mocked(current.control.prepareLocalEdge).mockImplementationOnce(async () => {
|
||||
await pending;
|
||||
return { type: "offer", sdp: "v=0\r\n" };
|
||||
});
|
||||
} else {
|
||||
current.peer().setLocalDescription.mockImplementationOnce(() => pending);
|
||||
}
|
||||
const rejected = expect(current.bridge.start()).rejects.toBeInstanceOf(NativeMediaBridgeError);
|
||||
if (stage === "answer") await vi.waitFor(() => expect(current.peer().setLocalDescription).toHaveBeenCalledOnce());
|
||||
current.bridge.dispose();
|
||||
complete();
|
||||
current.peer().dispatchEvent(Object.assign(new Event("icecandidate"), { candidate: null }));
|
||||
await rejected;
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
expect(current.control.acceptSignal).not.toHaveBeenCalled();
|
||||
expect(current.control.closeEdge).toHaveBeenCalledOnce();
|
||||
expect(current.onFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -241,6 +241,27 @@ function feedVideoStats(
|
||||
}
|
||||
|
||||
describe("embedded SFU browser transport", () => {
|
||||
it.each(["queued", "active"])("preserves publication after a %s candidate rejection", async (phase) => {
|
||||
const { publisher: host, pc, onDisconnected, video } = await publisher();
|
||||
const rejected = signal({ kind: "candidate", candidate: { candidate: "candidate:1 1 UDP 1 127.0.0.1 4100 typ host" } });
|
||||
const valid = signal({ kind: "candidate", candidate: { candidate: "candidate:2 1 UDP 1 127.0.0.1 4101 typ host" } });
|
||||
pc.addIceCandidate.mockRejectedValueOnce(new DOMException("Rejected candidate", "OperationError"));
|
||||
if (phase === "queued") {
|
||||
await host.acceptSignal(rejected);
|
||||
await host.acceptSignal(valid);
|
||||
}
|
||||
await host.acceptSignal(signal({ description: { type: "answer", sdp: "answer" } }));
|
||||
pc.state("connected");
|
||||
if (phase === "active") {
|
||||
await host.acceptSignal(rejected);
|
||||
await host.acceptSignal(valid);
|
||||
}
|
||||
expect(pc.addIceCandidate).toHaveBeenCalledWith(valid.candidate);
|
||||
expect(onDisconnected).not.toHaveBeenCalled();
|
||||
expect(pc.close).not.toHaveBeenCalled();
|
||||
expect(video.clones[0]!.readyState).toBe("live");
|
||||
});
|
||||
|
||||
it("publishes one bounded simulcast PC, offers before ICE and admits only the selected codec", async () => {
|
||||
const { publisher: host, pc, send, video } = await publisher();
|
||||
expect(pc.configuration).toEqual({ iceServers: [] });
|
||||
|
||||
Reference in new issue
Block a user