diff --git a/docs/project-memory.md b/docs/project-memory.md index a3e9549..3fc2d8e 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -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 diff --git a/docs/standards/media-quality.md b/docs/standards/media-quality.md index 1af96b8..be2a506 100644 --- a/docs/standards/media-quality.md +++ b/docs/standards/media-quality.md @@ -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. diff --git a/docs/standards/routing-transport.md b/docs/standards/routing-transport.md index de1db8c..4373175 100644 --- a/docs/standards/routing-transport.md +++ b/docs/standards/routing-transport.md @@ -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 diff --git a/docs/todo.md b/docs/todo.md index 7e12fae..693910e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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. diff --git a/internal/app/mediaedge/receiver.go b/internal/app/mediaedge/receiver.go index 6ef91f4..74c28dd 100644 --- a/internal/app/mediaedge/receiver.go +++ b/internal/app/mediaedge/receiver.go @@ -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 diff --git a/internal/app/mediaedge/receiver_test.go b/internal/app/mediaedge/receiver_test.go index 5e2fb91..ee1922f 100644 --- a/internal/app/mediaedge/receiver_test.go +++ b/internal/app/mediaedge/receiver_test.go @@ -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}) diff --git a/internal/app/mediaedge/source.go b/internal/app/mediaedge/source.go index 0e9cee6..a6db3c8 100644 --- a/internal/app/mediaedge/source.go +++ b/internal/app/mediaedge/source.go @@ -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) diff --git a/internal/app/mediaedge/source_test.go b/internal/app/mediaedge/source_test.go index 6f87a2e..d077bbe 100644 --- a/internal/app/mediaedge/source_test.go +++ b/internal/app/mediaedge/source_test.go @@ -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) diff --git a/scripts/app-native-host-gate.ts b/scripts/app-native-host-gate.ts index ca281f8..ff117da 100644 --- a/scripts/app-native-host-gate.ts +++ b/scripts/app-native-host-gate.ts @@ -186,11 +186,20 @@ async function selectNativeSource( kind: "window" | "display", title?: string, ): Promise { - await evaluate( - cdp, - page, - `document.querySelector('button[data-source-tab="${kind}"]')?.click()`, - Date.now() + 5_000, + await waitForValue( + (deadline) => evaluate( + 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:']" diff --git a/src/client/media/browser-encoding-producer.ts b/src/client/media/browser-encoding-producer.ts index 7432c94..990f3f1 100644 --- a/src/client/media/browser-encoding-producer.ts +++ b/src/client/media/browser-encoding-producer.ts @@ -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); } }; }; diff --git a/src/client/native/media-bridge.ts b/src/client/native/media-bridge.ts index 34045a0..574e1ae 100644 --- a/src/client/native/media-bridge.ts +++ b/src/client/native/media-bridge.ts @@ -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 = []; + 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, diff --git a/src/client/sfu/peer.ts b/src/client/sfu/peer.ts index 64556b6..05f0e54 100644 --- a/src/client/sfu/peer.ts +++ b/src/client/sfu/peer.ts @@ -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[] = []; - private remoteCandidates: RTCIceCandidateInit[] = []; + private remoteCandidates: NonNullable[] = []; 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"); diff --git a/src/client/webrtc/video-codec-preflight.ts b/src/client/webrtc/video-codec-preflight.ts index dd7a904..5c695e2 100644 --- a/src/client/webrtc/video-codec-preflight.ts +++ b/src/client/webrtc/video-codec-preflight.ts @@ -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 => { try { - await target.addIceCandidate(candidate); + await addRemoteIceCandidate(target, candidate); } catch { iceFailed = true; } diff --git a/tests/browser-encoding-producer.test.ts b/tests/browser-encoding-producer.test.ts index b3e3de7..77175b2 100644 --- a/tests/browser-encoding-producer.test.ts +++ b/tests/browser-encoding-producer.test.ts @@ -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(); diff --git a/tests/native-media-bridge.test.ts b/tests/native-media-bridge.test.ts index e9e602a..b745098 100644 --- a/tests/native-media-bridge.test.ts +++ b/tests/native-media-bridge.test.ts @@ -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 => 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(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(); + }); }); diff --git a/tests/sfu-client.test.ts b/tests/sfu-client.test.ts index 9311826..f3bd49c 100644 --- a/tests/sfu-client.test.ts +++ b/tests/sfu-client.test.ts @@ -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: [] });