Firefox · DOM
CVE-2026-118006
Logic Error in DOM
Overview
High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
MediaTransportHandlerSTSdom/media/webrtc/jsapi/MediaTransportHandler.cpp |
modified | |
MediaTransportImpldom/media/webrtc/jsapi/MediaTransportHandler.cpp |
modified | |
STSShutdownHandlerdom/media/webrtc/jsapi/MediaTransportHandler.cpp |
modified | |
MediaTransportImplNicerdom/media/webrtc/jsapi/MediaTransportHandler.cpp |
modified | |
ifdom/media/webrtc/jsapi/MediaTransportHandler.cpp |
modified | |
MediaTransportHandlerLocaldom/media/webrtc/jsapi/MediaTransportHandler.cpp |
modified | |
Postdom/media/webrtc/jsapi/MediaTransportHandler.cpp |
modified |
Files Changed
dom/media/webrtc/jsapi/MediaTransportHandler.cppdom/media/webrtc/jsapi/MediaTransportHandler.hdom/media/webrtc/jsapi/MediaTransportHandlerIPC.cppdom/media/webrtc/jsapi/MediaTransportHandlerIPC.hdom/media/webrtc/jsapi/moz.builddom/media/webrtc/tests/mochitests/mochitest_peerconnection.tomldom/media/webrtc/tests/mochitests/test_peerConnection_offline.htmlmedia/webrtc/signaling/gtest/mediapipeline_unittest.cppmedia/webrtc/signaling/gtest/mediatransporthandler_unittest.cppmedia/webrtc/signaling/gtest/moz.build
Patch
diff --git a/dom/media/webrtc/jsapi/MediaTransportHandler.cpp b/dom/media/webrtc/jsapi/MediaTransportHandler.cpp
index ea6b829529b..b22712ae96c 100644
--- a/dom/media/webrtc/jsapi/MediaTransportHandler.cpp
+++ b/dom/media/webrtc/jsapi/MediaTransportHandler.cpp
@@ -36,10 +36,13 @@
#include "mozilla/ProfilerMarkers.h"
#include "mozilla/PublicSSL.h" // For psm::InitializeCipherSuite
#include "mozilla/ReverseIterator.h"
+#include "mozilla/StopGapEventTarget.h"
+#include "mozilla/UniquePtr.h"
#include "mozilla/dom/RTCStatsReportBinding.h"
#include "nsDNSService2.h"
#include "nsFmtString.h"
#include "nsISocketTransportService.h"
+#include "nsSocketTransportService2.h"
#include "nss.h" // For NSS_NoDB_Init
#include "sdp/SdpAttribute.h"
#include "transport/runnable_utils.h"
@@ -62,20 +65,100 @@ static const char* mthLogTag = "MediaTransportHandler";
// missing keyframe self-heals via PLI/FIR.
static constexpr uint32_t kOldFlowGraceMs = 2000;
-class MediaTransportHandlerSTS : public MediaTransportHandler,
- public sigslot::has_slots<> {
+using ParsedIceServer = IceServerParser::ParsedIceServer;
+using IceLogPromise = MediaTransportHandler::IceLogPromise;
+
+// An underlying implementation wrapped by MediaTransportHandlerLocal. Unlike
+// MediaTransportHandler, this is not required to be threadsafe or refcounted.
+// Everything except Init must be run on the event target Init returns.
+class MediaTransportImpl {
+ public:
+ // MediaTransportEvents is how this will communicate with the wrapping
+ // MediaTransportHandlerLocal; this decouples the lifecycles of wrapper/impl.
+ explicit MediaTransportImpl(RefPtr<MediaTransportEvents> aEvents)
+ : mEvents(aEvents) {}
+ virtual ~MediaTransportImpl() = default;
+
+ // Called once, on any thread. Returns the event target every other call must
+ // be dispatched to, including our destruction (eg; a no-op runnable carrying
+ // a UniquePtr to us). The contract on this event target is as follows:
+ // 1 The event target either runs or destroys runnables in the order they
+ // were dispatched. If Dispatch returns an error, this counts as
+ // destruction of the runnable; all previously dispatched runnables must
+ // have already been run, destroyed, or leaked. This is all part of the
+ // contract of nsISerialEventTarget, but it bears repeating here.
+ // This is what makes it safe to carry bareptrs (MediaTransportImpl*) in
+ // these runnables, because the last runnable is the one that destroys us.
+ // 2 If this class must be destroyed on the event target, the event target
+ // must be accepting and running runnables. In other words, if the event
+ // target will stop accepting runnables (eg; STS shutdown), the impl must
+ // make itself safe to destroy on any thread *before* that happens.
+ // 3 The event target will accept runnables right away, even if it needs to
+ // perform async init before it begins actually running them.
+ virtual already_AddRefed<nsISerialEventTarget> Init() = 0;
+
+ virtual void CreateIceCtx(const std::string& aName) = 0;
+
+ virtual RefPtr<IceLogPromise> GetIceLog(const nsCString& aPattern) = 0;
+ virtual void ClearIceLog() = 0;
+ virtual void EnterPrivateMode() = 0;
+ virtual void ExitPrivateMode() = 0;
+
+ virtual void SetIceConfig(nsTArray<ParsedIceServer>&& aIceServers,
+ dom::RTCIceTransportPolicy aIcePolicy) = 0;
+ virtual void SetProxyConfig(NrSocketProxyConfig&& aProxyConfig) = 0;
+ virtual void EnsureProvisionalTransport(const std::string& aTransportId,
+ const std::string& aUfrag,
+ const std::string& aPwd,
+ int aComponentCount) = 0;
+ virtual void SetTargetForDefaultLocalAddressLookup(
+ const std::string& aTargetIp, uint16_t aTargetPort) = 0;
+ virtual void StartIceGathering(bool aDefaultRouteOnly,
+ bool aObfuscateHostAddresses,
+ const nsTArray<NrIceStunAddr>& aStunAddrs) = 0;
+ virtual void ActivateTransport(
+ const std::string& aTransportId, const std::string& aLocalUfrag,
+ const std::string& aLocalPwd, size_t aComponentCount,
+ const std::string& aUfrag, const std::string& aPassword,
+ const nsTArray<uint8_t>& aKeyDer, const nsTArray<uint8_t>& aCertDer,
+ SSLKEAType aAuthType, bool aDtlsClient, const DtlsDigestList& aDigests,
+ bool aPrivacyRequested) = 0;
+ virtual void RemoveTransportsExcept(
+ const std::set<std::string>& aTransportIds) = 0;
+ virtual void StartIceChecks(bool aIsControlling,
+ const std::vector<std::string>& aIceOptions) = 0;
+ virtual void AddIceCandidate(const std::string& aTransportId,
+ const std::string& aCandidate,
+ const std::string& aUfrag,
+ const std::string& aResolvedAddress) = 0;
+ virtual void UpdateNetworkState(bool aOnline) = 0;
+ virtual void SendPacket(const std::string& aTransportId,
+ MediaPacket&& aPacket) = 0;
+ virtual RefPtr<dom::RTCStatsPromise> GetIceStats(
+ const std::string& aTransportId, DOMHighResTimeStamp aNow) = 0;
+
+ protected:
+ const RefPtr<MediaTransportEvents> mEvents;
+};
+
+class STSShutdownHandler;
+
+class MediaTransportImplNicer final : public MediaTransportImpl,
+ public sigslot::has_slots<> {
public:
- explicit MediaTransportHandlerSTS();
+ explicit MediaTransportImplNicer(RefPtr<MediaTransportEvents> aEvents);
+ ~MediaTransportImplNicer();
+
+ already_AddRefed<nsISerialEventTarget> Init() override;
+ void CreateIceCtx(const std::string& aName) override;
RefPtr<IceLogPromise> GetIceLog(const nsCString& aPattern) override;
void ClearIceLog() override;
void EnterPrivateMode() override;
void ExitPrivateMode() override;
- void CreateIceCtx(const std::string& aName) override;
-
- nsresult SetIceConfig(const nsTArray<dom::RTCIceServer>& aIceServers,
- dom::RTCIceTransportPolicy aIcePolicy) override;
+ void SetIceConfig(nsTArray<ParsedIceServer>&& aIceServers,
+ dom::RTCIceTransportPolicy aIcePolicy) override;
// We will probably be able to move the proxy lookup stuff into
// this class once we move mtransport to its own process.
@@ -125,12 +208,16 @@ class MediaTransportHandlerSTS : public MediaTransportHandler,
RefPtr<dom::RTCStatsPromise> GetIceStats(const std::string& aTransportId,
DOMHighResTimeStamp aNow) override;
- void Shutdown();
+ // Releases everything that must be released on STS. Afterwards, this may be
+ // destroyed on any thread. Called by STSShutdownHandler, and on destruction.
+ // This is how we satisfy item 2 in the contract on Init().
+ void DeinitSts();
private:
- void Destroy() override;
- void DestroyFinal();
- void Shutdown_s();
+ void InitMain(const RefPtr<StopGapEventTarget>& aTarget);
+ void InitSts(const RefPtr<STSShutdownHandler>& aHandler,
+ const NrIceCtx::GlobalConfig& aConfig,
+ const RefPtr<StopGapEventTarget>& aTarget);
RefPtr<TransportFlow> CreateTransportFlow(
const std::string& aTransportId, bool aIsRtcp,
const RefPtr<DtlsIdentity>& aDtlsIdentity, bool aDtlsClient,
@@ -144,6 +231,12 @@ class MediaTransportHandlerSTS : public MediaTransportHandler,
Transport() = default;
Transport(const Transport&) = delete;
Transport& operator=(const Transport&) = delete;
+ // The close timer's callback holds a plain pointer to us.
+ ~Transport() {
+ if (mCloseTimer) {
+ mCloseTimer->Cancel();
+ }
+ }
RefPtr<TransportFlow> GetCurrent(bool aIsRtcp) const {
return (aIsRtcp && mRtcpFlow) ? mRtcpFlow : mFlow;
@@ -185,7 +278,7 @@ class MediaTransportHandlerSTS : public MediaTransportHandler,
// These are set during DTLS restart to avoid interrupting media flow.
RefPtr<TransportFlow> mOldFlow;
RefPtr<TransportFlow> mOldRtcpFlow;
- // Used by MediaTransportHandlerSTS to schedule closure of old flows.
+ // Used by MediaTransportImplNicer to schedule closure of old flows.
// This class is not very suitable for scheduling these timers itself,
// because it is not refcounted.
nsCOMPtr<nsITimer> mCloseTimer;
@@ -222,16 +315,6 @@ class MediaTransportHandlerSTS : public MediaTransportHandler,
std::string mReportedAlpn;
};
- using MediaTransportHandler::OnAlpnNegotiated;
- using MediaTransportHandler::OnCandidate;
- using MediaTransportHandler::OnCandidateError;
- using MediaTransportHandler::OnConnectionStateChange;
- using MediaTransportHandler::OnEncryptedSending;
- using MediaTransportHandler::OnGatheringStateChange;
- using MediaTransportHandler::OnPacketReceived;
- using MediaTransportHandler::OnRtcpStateChange;
- using MediaTransportHandler::OnStateChange;
-
void OnGatheringStateChange(const std::string& aTransportId,
NrIceMediaStream::GatheringState aState);
void OnConnectionStateChange(NrIceMediaStream* aIceStream,
@@ -265,11 +348,16 @@ class MediaTransportHandlerSTS : public MediaTransportHandler,
dom::RTCStatsCollection* aStats,
dom::RTCTransportStats& aTransport) const;
- virtual ~MediaTransportHandlerSTS() = default;
nsCOMPtr<nsISerialEventTarget> mStsThread;
+ // Only set if we registered with it, only touched on STS.
+ RefPtr<STSShutdownHandler> mShutdownHandler;
RefPtr<NrIceCtx> mIceCtx;
RefPtr<NrIceResolver> mDNSResolver;
std::map<std::string, Transport> mTransports;
+ // We should not need this if STS is keeping its end of the contract around
+ // shutdown, but we're going to double-check just in case. If STS has
+ // messed up, we'll convert the bug into a safe crash.
+ std::atomic<bool> mSTSOnly{false};
bool mHideLocalPrflx = false;
bool mTurnDisabled = false;
uint32_t mMinDtlsVersion = 0;
@@ -280,10 +368,173 @@ class MediaTransportHandlerSTS : public MediaTransportHandler,
Maybe<NrIceCtx::NatSimulatorConfig> mNatConfig;
std::set<std::string> mSignaledAddresses;
+};
- // Init can only be done on main, but we want this to be usable on any thread
- using InitPromise = MozPromise<bool, std::string, false>;
- RefPtr<InitPromise> mInitPromise;
+// Refcounted front for a MediaTransportImpl, usable on any thread. Every call
+// is dispatched to the implementation's event target, and so is the
+// implementation's destruction, which therefore happens after every call that
+// could still act on it.
+class MediaTransportHandlerLocal final : public MediaTransportHandler {
+ public:
+ MediaTransportHandlerLocal()
+ : mImpl(MakeUnique<MediaTransportImplNicer>(mEvents)),
+ mTarget(mImpl->Init()) {}
+
+ RefPtr<IceLogPromise> GetIceLog(const nsCString& aPattern) override {
+ return InvokeAsync(mTarget, __func__, [impl = mImpl.get(), aPattern] {
+ return impl->GetIceLog(aPattern);
+ });
+ }
+
+ void ClearIceLog() override {
+ Post(__func__, [](MediaTransportImpl& aImpl) { aImpl.ClearIceLog(); });
+ }
+
+ void EnterPrivateMode() override {
+ Post(__func__, [](MediaTransportImpl& aImpl) { aImpl.EnterPrivateMode(); });
+ }
+
+ void ExitPrivateMode() override {
+ Post(__func__, [](MediaTransportImpl& aImpl) { aImpl.ExitPrivateMode(); });
+ }
+
+ void CreateIceCtx(const std::string& aName) override {
+ Post(__func__,
+ [aName](MediaTransportImpl& aImpl) { aImpl.CreateIceCtx(aName); });
+ }
+
+ nsresult SetIceConfig(const nsTArray<dom::RTCIceServer>& aIceServers,
+ dom::RTCIceTransportPolicy aIcePolicy) override {
+ auto result = IceServerParser::Parse(aIceServers);
+ if (result.isErr()) {
+ // Discard the detailed ErrorResult; callers at this level use nsresult.
+ result.unwrapErr().SuppressException();
+ return NS_ERROR_FAILURE;
+ }
+ Post(__func__, [entries = result.unwrap(),
+ aIcePolicy](MediaTransportImpl& aImpl) mutable {
+ aImpl.SetIceConfig(std::move(entries), aIcePolicy);
+ });
+ return NS_OK;
+ }
+
+ void SetProxyConfig(NrSocketProxyConfig&& aProxyConfig) override {
+ Post(__func__, [aProxyConfig = std::move(aProxyConfig)](
+ MediaTransportImpl& aImpl) mutable {
+ aImpl.SetProxyConfig(std::move(aProxyConfig));
+ });
+ }
+
+ void EnsureProvisionalTransport(const std::string& aTransportId,
+ const std::string& aUfrag,
+ const std::string& aPwd,
+ int aComponentCount) override {
+ Post(__func__, [=](MediaTransportImpl& aImpl) {
+ aImpl.EnsureProvisionalTransport(aTransportId, aUfrag, aPwd,
+ aComponentCount);
+ });
+ }
+
+ void SetTargetForDefaultLocalAddressLookup(const std::string& aTargetIp,
+ uint16_t aTargetPort) override {
+ Post(__func__, [=](MediaTransportImpl& aImpl) {
+ aImpl.SetTargetForDefaultLocalAddressLookup(aTargetIp, aTargetPort);
+ });
+ }
+
+ void StartIceGathering(bool aDefaultRouteOnly, bool aObfuscateHostAddresses,
+ const nsTArray<NrIceStunAddr>& aStunAddrs) override {
+ Post(__func__,
+ [=, stunAddrs = aStunAddrs.Clone()](MediaTransportImpl& aImpl) {
+ aImpl.StartIceGathering(aDefaultRouteOnly, aObfuscateHostAddresses,
+ stunAddrs);
+ });
+ }
+
+ void ActivateTransport(
+ const std::string& aTransportId, const std::string& aLocalUfrag,
+ const std::string& aLocalPwd, size_t aComponentCount,
+ const std::string& aUfrag, const std::string& aPassword,
+ const nsTArray<uint8_t>& aKeyDer, const nsTArray<uint8_t>& aCertDer,
+ SSLKEAType aAuthType, bool aDtlsClient, const DtlsDigestList& aDigests,
+ bool aPrivacyRequested) override {
+ Post(__func__, [=, keyDer = aKeyDer.Clone(),
+ certDer = aCertDer.Clone()](MediaTransportImpl& aImpl) {
+ aImpl.ActivateTransport(aTransportId, aLocalUfrag, aLocalPwd,
+ aComponentCount, aUfrag, aPassword, keyDer,
+ certDer, aAuthType, aDtlsClient, aDigests,
+ aPrivacyRequested);
+ });
+ }
+
+ void RemoveTransportsExcept(
+ const std::set<std::string>& aTransportIds) override {
+ Post(__func__, [=](MediaTransportImpl& aImpl) {
+ aImpl.RemoveTransportsExcept(aTransportIds);
+ });
+ }
+
+ void StartIceChecks(bool aIsControlling,
+ const std::vector<std::string>& aIceOptions) override {
+ Post(__func__, [=](MediaTransportImpl& aImpl) {
+ aImpl.StartIceChecks(aIsControlling, aIceOptions);
+ });
+ }
+
+ void AddIceCandidate(const std::string& aTransportId,
+ const std::string& aCandidate, const std::string& aUfrag,
+ const std::string& aResolvedAddress) override {
+ Post(__func__, [=](MediaTransportImpl& aImpl) {
+ aImpl.AddIceCandidate(aTransportId, aCandidate, aUfrag, aResolvedAddress);
+ });
+ }
+
+ void UpdateNetworkState(bool aOnline) override {
+ Post(__func__,
+ [=](MediaTransportImpl& aImpl) { aImpl.UpdateNetworkState(aOnline); });
+ }
+
+ void SendPacket(const std::string& aTransportId,
+ MediaPacket&& aPacket) override {
+ Post(__func__, [aTransportId, aPacket = std::move(aPacket)](
+ MediaTransportImpl& aImpl) mutable {
+ aImpl.SendPacket(aTransportId, std::move(aPacket));
+ });
+ }
+
+ RefPtr<dom::RTCStatsPromise> GetIceStats(const std::string& aTransportId,
+ DOMHighResTimeStamp aNow) override {
+ return InvokeAsync(mTarget, __func__,
+ [impl = mImpl.get(), aTransportId, aNow] {
+ return impl->GetIceStats(aTransportId, aNow);
+ });
+ }
+
+ private:
+ ~MediaTransportHandlerLocal() {
+ // If Dispatch fails, the runnable is synchronously destroyed, taking the
+ // impl with it. This is why we need item 2 in the contract on
+ // MediaTransportImpl::Init; the impl must become safe to destroy on any
+ // thread if the event target will stop accepting runnables.
+ (void)mTarget->Dispatch(
+ NS_NewRunnableFunction(
+ __func__, [impl = std::move(mImpl)]() mutable {}),
+ NS_DISPATCH_FALLIBLE);
+ }
+
+ template <typename Function>
+ void Post(const char* aName, Function&& aFunction) {
+ (void)mTarget->Dispatch(
+ NS_NewRunnableFunction(aName,
+ [impl = mImpl.get(),
+ function = std::forward<Function>(
+ aFunction)]() mutable { function(*impl); }),
+ NS_DISPATCH_FALLIBLE);
+ }
+
+ // Only released on destruction.
+ UniquePtr<MediaTransportImpl> mImpl;
+ const nsCOMPtr<nsISerialEventTarget> mTarget;
};
/* static */
@@ -294,87 +545,120 @@ already_AddRefed<MediaTransportHandler> MediaTransportHandler::Create() {
StaticPrefs::network_process_enabled()) {
result = MakeRefPtr<MediaTransportHandlerIPC>();
} else {
- result = MakeRefPtr<MediaTransportHandlerSTS>();
+ result = MakeRefPtr<MediaTransportHandlerLocal>();
}
result->Initialize();
return result.forget();
}
-class STSShutdownHandler : public nsISTSShutdownObserver {
+// Tells every live MediaTransportImplNicer to release what it holds on STS
+// when STS shuts down. Observe() is called on main just before the STS thread
+// is joined, so the work has to be queued there right away.
+class STSShutdownHandler final : public nsISTSShutdownObserver {
public:
- NS_DECL_ISUPPORTS
-
... (file diff truncated)
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/dom/media/webrtc/tests/mochitests/mochitest_peerconnection.toml b/dom/media/webrtc/tests/mochitests/mochitest_peerconnection.toml
index 294a37c70f9..91c77d77156 100644
--- a/dom/media/webrtc/tests/mochitests/mochitest_peerconnection.toml
+++ b/dom/media/webrtc/tests/mochitests/mochitest_peerconnection.toml
@@ -302,6 +302,8 @@ scheme = "http"
["test_peerConnection_offerRequiresReceiveVideoAudio.html"]
+["test_peerConnection_offline.html"]
+
["test_peerConnection_portRestrictions.html"]
["test_peerConnection_promiseSendOnly.html"]
diff --git a/dom/media/webrtc/tests/mochitests/test_peerConnection_offline.html b/dom/media/webrtc/tests/mochitests/test_peerConnection_offline.html
new file mode 100644
index 00000000000..97dcf07be53
--- /dev/null
+++ b/dom/media/webrtc/tests/mochitests/test_peerConnection_offline.html
@@ -0,0 +1,96 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <script type="application/javascript" src="pc.js"></script>
+</head>
+<body>
+<pre id="test">
+<script type="application/javascript">
+ createHTML({
+ bug: "2074202",
+ title: "Going offline and back online with live PeerConnections"
+ });
+
+// Flips the offline state in the parent, which passes it on to every other
+// process, including the one running the ICE stack.
+const offlineScript = SpecialPowers.loadChromeScript(() => {
+ /* eslint-env mozilla/chrome-script */
+ addMessageListener("set-offline", offline => {
+ Services.io.offline = offline;
+ });
+});
+
+async function setOffline(offline) {
+ const changed = new Promise(resolve => {
+ const observer = {
+ observe(subject, topic) {
+ SpecialPowers.removeObserver(observer, topic);
+ resolve();
+ },
+ };
+ SpecialPowers.addObserver(observer, "network:offline-status-changed");
+ });
+ offlineScript.sendAsyncMessage("set-offline", offline);
+ await changed;
+ is(SpecialPowers.Services.io.offline, offline,
+ `This process is ${offline ? "offline" : "online"}`);
+}
+
+async function connect() {
+ const pc1 = new RTCPeerConnection();
+ const pc2 = new RTCPeerConnection();
+ pc1.onicecandidate = e => pc2.addIceCandidate(e.candidate);
+ pc2.onicecandidate = e => pc1.addIceCandidate(e.candidate);
+ const dc1 = pc1.createDataChannel("no-signal-bars");
+ const dc2 = new Promise(r => (pc2.ondatachannel = e => r(e.channel)));
+ const opened = haveEvent(dc1, "open");
+ await pc1.setLocalDescription();
+ await pc2.setRemoteDescription(pc1.localDescription);
+ await pc2.setLocalDescription();
+ await pc1.setRemoteDescription(pc2.localDescription);
+ await opened;
+ return { pc1, pc2, dc1, dc2: await dc2 };
+}
+
+async function sendAcross({ dc1, dc2 }, text) {
+ const received = haveEvent(dc2, "message");
+ dc1.send(text);
+ is((await received).data, text, "Message made it across");
+}
+
+runNetworkTest(async () =>
+ // Otherwise the link service may put us back online behind our back.
+ withPrefs([["network.manage-offline-status", false]], async () => {
+ const before = await connect();
+ await sendAcross(before, "before");
+
+ info("Going offline with a connected pair");
+ await setOffline(true);
+ // The ICE stack loses its sockets here. We do not insist on any particular
+ // reaction, only that nothing crashes and everything can still be torn down.
+ await wait(1000);
+ info(`iceConnectionState while offline: ${before.pc1.iceConnectionState}, ` +
+ `${before.pc2.iceConnectionState}`);
+
+ try {
+ new RTCPeerConnection();
+ ok(false, "Creating an RTCPeerConnection while offline should throw");
+ } catch (e) {
+ is(e.name, "InvalidStateError",
+ "Creating an RTCPeerConnection while offline throws InvalidStateError");
+ }
+
+ await setOffline(false);
+ before.pc1.close();
+ before.pc2.close();
+
+ info("Back online; a new pair must connect");
+ const after = await connect();
+ await sendAcross(after, "after");
+ after.pc1.close();
+ after.pc2.close();
+ }));
+</script>
+</pre>
+</body>
+</html>
Loading diff…
References
On This Page