浏览器调试 WebRTC 高级技巧

avatar
FE @字节跳动
  1. 背景

在浏览器里开发WebRTC项目,调试利器就是WebRTC Internals工具,在之前的WebRTC Internals工具在项目中的实践文章里有详细说明,Internals工具对于分析SDP历史、实时码率等方面比较突出,但在调试底层问题时存在较大局限性,本文通过3个示例来说明从标准、源码日志、调试等方面来展示浏览器侧开发WebRTC的技巧。

  1. Show me the case

  1. 看不懂JS报错信息?去源码里找找原因

Firefox setRemoteDescription报错:Malformed fingerprint token

在与Firefox兼容的过程中遇到了许多错误,其中有个错误在调用setRemoteDescription时就会抛出,进而阻断后续逻辑。

错误捕获

很幸运,在控制台就看到了错误信息:Malformed fingerprint token

问题定位

这是当时设置的SDP内容,错误非常贴心地告诉第4行的token不正确,但是如何不正确,这对于不熟悉fingerprint标准的人来说是无法理解的,并且这个错误信息在网络上找不到有效解决方式的。

v=0
o=mozilla...THIS_IS_SDPARTA-99.0 5140776901542294402 0 IN IP4 0.0.0.0 
s=- 
t=0 0 
a=fingerprint:sha-256 dd:f5:80:0e:d1:91:12:2b:b6:0e:5d:eb:02:d9:31:72:85:6d:77:ab:4b:67:94:14:e3:cd:9c:6a:3f:2d:af:bc 
a=ice-options:trickle 

此时可以直接去看下fingerprint的RFC标准文档,或者看下Firefox浏览器这个错误产生的源代码。可以通过Firefox源码平台搜索这个错误字符串(一般需要搜索不可分割的字符串,浏览器错误信息里也会包含变量,要注意搜索字符串不能包含变量),可以看到只有一处代码:

通过源码跟踪:

  1. 看到报错上面会有一个ParseFingerprint的过程,继续追踪

searchfox.org/mozilla-cen…

    std::string fingerprintToken(fingerprintAttr.substr(start));

    std::vector<uint8_t> fingerprint =
        SdpFingerprintAttributeList::ParseFingerprint(fingerprintToken);
    if (fingerprint.empty()) {
      results.AddParseError(lineNumber, "Malformed fingerprint token");
      return false;
    }

2. ParseFingerprint的注释贴心地告诉他是根据哪个RFC的哪节来实现的,可以结合来看下

searchfox.org/mozilla-cen…

通读看下来注释里又双叕贴心地告诉有个地方会抛error,再看是有条件触发就会抛,再看上面转换的FromUppercaseHex,果然我们实际拿真实的数据来模拟下调用流程,发现d在FromUppercaseHex时就会返回16,这是一个invalid的值,就是这个原因导致了错误。

看FromUppercaseHex实现大概可以猜出有效值应该是在[0-9A-F]这个范围内的,原来是设置的小写字母不合法

static uint8_t FromUppercaseHex(char ch) {
  if ((ch >= '0') && (ch <= '9')) {
    return ch - '0';
  }
  if ((ch >= 'A') && (ch <= 'F')) {
    return ch - 'A' + 10;
  }
  return 16;  // invalid
}

// Parse the fingerprint from RFC 4572 Section 5 attribute format
std::vector<uint8_t> SdpFingerprintAttributeList::ParseFingerprint(
    const std::string& str) {
  size_t targetSize = (str.length() + 1) / 3;
  std::vector<uint8_t> fp(targetSize);
  size_t fpIndex = 0;

  if (str.length() % 3 != 2) {
    fp.clear();
    return fp;
  }

  for (size_t i = 0; i < str.length(); i += 3) {
    uint8_t high = FromUppercaseHex(str[i]);
    uint8_t low = FromUppercaseHex(str[i + 1]);
    if (high > 0xf || low > 0xf ||
        (i + 2 < str.length() && str[i + 2] != ':')) {
      fp.clear();  // error
      return fp;
    }
    fp[fpIndex++] = high << 4 | low;
  }
  return fp;
}

3. 最看查看下RFC是怎么规定的:uppercase hexadecimal bytes

datatracker.ietf.org/doc/html/rf…

 A fingerprint is represented in SDP as an attribute (an 'a' line).
   It consists of the name of the hash function used, followed by the
   hash value itself.  The hash value is represented as a sequence of
   uppercase hexadecimal bytes, separated by colons.

最终破案,SDP里的fingerprint是需要大写,而 SFU 返回的小写,因此导致的报错,最后由SFU修改为大写。

换位思考

那为何Chrome浏览器能运行正常?是Chrome执行标准不严格嘛?

我们知道RFC开头都会有一段描述来说明该篇RFC文档中哪些是MUST要遵守的,哪些是RECOMMENDED推荐的,注意这些单词都是大写,表示强调。而我们再回头看下上面对于大小写的描述:The hash value is represented as a sequence ofuppercase hexadecimal bytes,尴尬的是里面没有任何表示执行级别单词,那这么看来,也有可能是Firefox浏览器的实现有点矫枉过正了。

  1. JS没有报错?打开c++日志捞下异常

Chrome浏览器"偶现"播放无声

业务遇到偶尔无法播放声音,经过埋点上报看到的现象,与自动播放失败非常相似,但又不是自动播放失败,复现后,JS调用没有报错,但音频功能就是不正常

错误捕获

JS没有报错,只能先看一下浏览器底层C++的日志有没有报错,浏览器/WebRTC代码里也是有加了很多的log(日志开启的方式见附录),拿到几十万行的日志并不是很容易能找到想要的错误日志,以现在的经验看,找出所有的WARNING/ERROR记录下来,然后和正常case进行比对,多出来的WARNING/ERROR才是优先排查的内容。

后面就锁定了一些异常日志:

  • media_stream_manager.cc(705)] AOC::OnMoreData => (average audio level=-inf dBFS)

  • ERROR:audio_low_latency_output_win.cc(502)] WAOS::Run => (ERROR: Failed to enable MMCSS (error code=565))

  • WARNING:packet_buffer.cc(175)] Packet buffer flushed, 200 packets discarded.

  • ERROR:audio_manager_base.cc(213)] Number of opened output audio streams 50 exceed the max allowed number 50

问题定位

通过上面一些异常日志结合可以想到,audio_manager_base.cc里面管理的output audio stream超过50,导致功能不正常?最后找到AOC::CreateStream => (state=created)日志的数量与AOC::Close => (state=closed)不匹配,差值是40多。

C++底层类是由于JS上层调用产出的,那使用极简Demo和有问题的JS代码去验证下有什么差异,测试结果是使用极简Demo,Create与Close的数量是相等的,而使用有问题的JS代码每次1v1通话结束后,Create的数量都会比Close多2个。

在AOC::CreateStream创建日志的上方附近找发现有AudioContext,JS代码确实有使用AudioContext来计算播放声音,既然找到了一个怀疑点,再使用一个空的HTML来只new AudioContext验证看下c++日志是否有AOC::CreateStream,结果确定有,那就破案了:JS代码为每一路流都创建了AudioContext,并且在流销毁的时候没有对AudioContext close

使用new AudioContext来验证是否有CreateStream日志

最后看下源码,原来是为了解决过多Audio标签导致创建过多的output stream导致的页面卡顿。但只在C++打error日志,JS层毫无感知,真的是一个最优解吗?

  // Limit the number of audio streams opened. This is to prevent using
  // excessive resources for a large number of audio streams. More
  // importantly it prevents instability on certain systems.
  // See bug: http://crbug.com/30242.
  if (num_output_streams_ >= max_num_output_streams_) {
    LOG(ERROR) << "Number of opened output audio streams "
               << num_output_streams_ << " exceed the max allowed number "
               << max_num_output_streams_;
    return nullptr;
  }

最最后可以使用这个Demo来感受下这个问题(先创建50个AudioContext,然后再采集音频,此时页面上的音量波动是不生效的):

webrtc.github.io/samples/src…

以微知著

解决这个问题我们并没有研究源码是怎么运行的,只是知道上层肯定会影响底层的机制,通过日志的对比,Demo的反复验证得出结论解决的问题。

从这个Case我们也可以知道:

  • 在正常的WebRTC应用或者需要音频播放的场景都会有同时创建50个的限制,如果真有这种场景应该及时释放掉暂时不需要的资源,或者如果要使用到AudioContext计算音量,其实1个AudioContext就可以完成,并不需要每个Stream创建一个AudioContext;
  • 不止output stream,其他底层的类肯定也会有类似限制,我们做video也做了极限测试,发现video的临界是1001

  1. 浏览器Crash?用VS Debug直接找到崩溃代码

Chrome 73/74版本拉流Crash

在测试Chrome 73/74版本时,一旦订阅了SFU的流,浏览器Tab就会突发的Crash,Crash时控制台日志可以指示SDK的代码运行到哪行,但是没有任何错误信息,因此也只能是通过Visual Studio来调试下浏览器看到Crash最后运行的代码。

错误捕获

Crash后会自动断点,此时点击显示调用堆栈(如果调用堆栈一直是空白的,就使用重启大法尝试恢复下),然后在堆栈中右击,点击加载符号,最终可以显示调用栈,因为是WebRTC逻辑异常,我们直接找到最后的RTC代码位置(Chrome比较早的版本,虽然可以加载到源码,但是指示的行数不一定正确,因此只看调用的函数就可以):

问题定位

以下的分析需要一点点RTC的基础知识。

看调用栈是从OnPacketReceived开始,到Parse RTP包的扩展头报错,那怀疑是否RTP扩展头数据有异常。

RTP是WebRTC里数据包的格式,RTP又可以携带多个扩展头信息,RTP协议与RTP扩展格式在RFC 3550中有详细定义。WebRTC使用RTP扩展头又分为One-byte header和Two-byte header,这个在RFC 8285中有明确说明,不同浏览器对于RTP扩展头的支持能力又可以在SDP中看到,比如这些:

a=extmap:12 http://www.webrtc.org/experiments/rtp-hdrext/color-space
a=extmap:9 urn:ietf:params:rtp-hdrext:sdes:mid
a=extmap:13 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id
a=extmap:14 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id

回到断点位置,可以看到有个局部变量size=32,可能指示出data的长度是32,通过Wireshark抓包证实下:

可以看到确实扩展id为13的内容长度是32 byte(Two-byte),不仅如此,还发现后面的数据包还携带了One-byte的扩展头

所谓One-byte或者Two-byte扩展头是指,扩展头的ID+LEN共占的字节长度,以One-byte为例,id占前4位,LEN占后4位(LEN的值就为0~15,实际字节数是len+1),也就是One-byte扩展头的value长度为16,浏览器如果既支持One-byte又支持Two-byte会在SDP中有a=extmap-allow-mixed,查看Chrome73浏览器的SDP果然没有带此标记,所以说SFU返回的Two-byte header肯定是不合规范的

那最后看下代码中的RTC_CHECK_LE(size, kMaxSize)语句,kMaxSize发现定义为16,由此可判断该不合法的扩展头导致浏览器的Crash,这里只是分析,如果要实锤还需要做对比测试,经过修改SDP没有协商出id为13的rtp-stream-id扩展头(手动删除一下offer里的rtp-stream-id),浏览器工作正常。

M73版本只支持One-byte扩展头,value长度最大为16

  1. how to troubleshoot

有同学可能认为解决底层问题必须要熟悉浏览器代码,熟悉各种协议标准,诚然对底层代码越熟悉就能越快地定位问题,但是我们掌握了一定的技巧和方法论后,也可以尝试摸索地去定位、解决。这个问题也可以作为契机来学习一下相关的底层代码和网络协议。

  1. 错误捕获

首先看能否在控制台有错误信息,如果没有,可以尝试使用一下chrome canary或者dev版本(具体下载地址见 chrome开发者专用的每日构建版),这些版本可能会打印额外的日志,比如之前遇到的在release版本只有Permission denied的报错信息,而在dev版本有额外安全策略的日志。

如果通过以上步骤没有拿到有效信息,我们可以通过浏览器自己答应的运行日志进一步排查。浏览器日志在正常运行时是关闭的,想要打开参考以下方法:

通过以上方式抓取一份异常日志和一份正常日志,过滤出ERROR和WARNING日志和关键词的日志,两份日志做下diff,重点关注复现时日志多出来的ERROR,因为浏览器即使正常运行的时候也会打一些WARNING,这些不是我们关注的内容。再进一步逐一分析这些错误信息。

最后还没有任何收获的话,就只能上visual studio去运行调试下浏览器,如果走到这步那确实是一个棘手的问题,也需要对源码的了解,不然断点都不知道打在哪里。VS调试比较适用Crash的问题,发生Crash后会直接断点停到最后运行处。

  1. Chrome源码调试

本章重点讲解windows使用Visual Studio编译chrome并进行源码调试的步骤

准备:

  1. Chrome源码获取

source.chromium.org/chromium

  1. 编译调试参考:

www.chromium.org/developers/…

www.chromium.org/developers/…

在Windows上,不下载编译,直接调试release版chrome,具体参考一下步骤:

  1. 安装并打开Visual Studio:下载地址
  2. 扩展->管理扩展,在窗口中搜索Microsoft Child Process Debugging Power Tool,安装多进程调试插件

  1. 文件->打开->项目/解决方案,找到并选择chrome.exe
  2. 调试->其他调试目标->Child Process Debugging Settings...,选中Enable child process debugging,并保存

  1. 工具->选项,在调试->常规里,勾选“启用源服务器支持”

  1. 工具->选项,在调试->符号里,添加chrome的符号服务器:chromium-browser-symsrv.commondatastorage.googleapis.com,并设置仅加载指定的模块,指定包含的模块里添加chrome.dll

  1. 启动程序,首次加载符号耗时会较长(注意在启动前要关闭已打开的Chrome)
  2. 在解决方案资源管理器里,会出现外部源,其中可查看源代码

  1. 设置断点并调试

  1. 错误分析

拿到错误信息后我们就可以进一步分析,首先可以万事stackoverflow,如果找到错误那就万事大吉。

其次JS和c++代码都有对应,底层问题大概率也是JS调用引起的,这就要极致分析看有无相关信息,然后再对比下自己的代码,看能否解决问题。

如果上述方式仍无进展,最后就深入下源码,切入点仍然是报错信息,然后找到对应代码后查看前后逻辑,也可以直接在VS里打断点直接调试: