介绍 sourcekitd 压力测试器

Sourcekitd 为关键编辑器功能提供数据支持,例如 Xcode 和最近发布的 SourceKit-LSP 中 Swift 文件的代码补全、语义高亮和重构。为了帮助提高其健壮性,我们引入了一个新工具 sourcekitd 压力测试器,在过去几个月中,它已帮助发现了 91 个可复现的 sourcekitd 崩溃、断言失败和挂起。这篇文章介绍了压力测试器的实现、其在 Swift 的 CI 和 PR 测试中的部署,以及 Swift 开发者如何在其自己的项目上运行它,以帮助改善每个人的 Swift 编辑体验。

关于 sourcekitd 的一些背景知识

Sourcekitd 设计为服务,并使用请求-响应模型与 Xcode 和其他客户端就一组 Swift 源文件进行通信。在深入了解 sourcekitd 如何进行压力测试之前,了解 sourcekitd 支持的请求类型、它们返回的信息以及客户端功能通常依赖于哪些信息会很有帮助。下表总结了压力测试器当前练习的请求子集

请求类型 行为和响应 编辑器功能
EditorOpen 打开 Swift 文档,内容可以是提供的内容,也可以是给定路径处的文件内容。返回语法高亮和结构信息。 语法高亮,代码折叠
EditorReplaceText 用给定的(可能为空的)字符串替换打开文档中的文本范围。返回更新后的语法高亮和结构信息 语法高亮,代码折叠
EditorClose 关闭打开的文档,释放关联的资源  
CursorInfo 返回有关打开文档中给定源位置的符号出现的信息,包括其类型、关联的文档和适用的重构类型(当使用提供的编译器参数编译时) 跳转到定义,快速帮助,重构
CodeComplete 返回在打开文档中给定源位置的代码补全结果(当使用提供的编译器参数编译时) 代码补全
RangeInfo 返回在打开文档中给定源范围的适用重构类型(当使用提供的编译器参数编译时) 重构
SemanticRefactoring 返回为在打开文档中给定源位置应用的提供的重构类型执行的编辑(当使用提供的编译器参数编译时) 重构

在此集合中,主要有两类请求:语法请求和语义请求。

语法请求 包括 EditorOpen、EditorReplaceText 和 EditorClose。这些用于使客户端关心的一组 Swift 文档的状态与 sourcekitd 同步。客户端发送它们以使用这些文档的文本内容更新 sourcekitd(当它们被打开和编辑时),作为响应,sourcekitd 提供最新的语法范围和结构信息,这些信息通常用于实现语法高亮、代码折叠和其他语法感知功能。

语义请求 包括上表中列出的其余请求。这些请求提供有关打开文档之一中的特定源范围 (RangeInfo) 或位置(CursorInfo、CodeComplete、SemanticRefactoring)的信息,并且需要对文档及其相关文件和模块进行语义理解。这就是为什么它们都将编译器参数作为输入。这些请求支持一系列编辑器功能,包括跳转到定义、代码补全、快速帮助和重构。

sourcekitd 压力测试

为了帮助查找 sourcekitd 中的崩溃、断言失败、挂起和其他故障,最新的 swift.org 主干开发快照(macOS 版)现在包含了 sourcekitd 压力测试器。如果您查看 usr/bin 目录,您会看到实际上有两个新的可执行文件

本节介绍这两个实用程序如何工作以帮助查找和报告 sourcekitd 中的问题。注意:虽然这些可执行文件目前仅在 macOS 工具链中可用,但 Linux 支持没有任何根本性的阻碍。只是尚未实现。

sourcekitd 压力测试器:sk-stress-test

$ sk-stress-test <选项> <源文件> swiftc <编译器参数>

压力测试器接受单个 Swift 源文件作为输入,以及用于编译它的编译器参数。基于这些,它会生成一系列 sourcekitd 请求,以打开、修改、查询和关闭单个 Swift 文档。这些请求中的每一个都是同步发送的,一个接一个,如果第一个请求导致 sourcekitd 崩溃、挂起或返回未能通过基本检查的响应,则会失败,否则会成功。当发现问题时,它会输出重现问题所需的详细信息,包括触发请求以及发送请求之前打开文档的状态,因为较早的 EditorReplaceText 请求可能已对其进行了修改。

由于压力测试器的目标是找到触发 sourcekitd 故障的请求,因此其实现中最有趣的部分是它如何决定要发送的请求序列。目前,它仅根据提供的源文件的语法信息,按照四种受支持的策略之一生成请求。使用哪种策略由 --rewrite-mode 选项控制。这些初始策略的一个共同特点是,它们都围绕以各种方式重写输入 Swift 源文件或按原样使用它。这产生了一个很好的效果,即它们发现的问题发生在仍然看起来像 Swift 程序员编写的代码的源代码中,因此表面上更可能在实践中遇到。也就是说,我们很乐意看到未来添加更多方法,因此如果这是一个您感兴趣的领域,请查看 项目自述文件,以获取有关贡献的说明。

当前支持的策略是

  1. 默认 (--rewrite-mode=none)

    在此模式下,将发送 EditorOpen 请求以使用输入文件的内容打开 Swift 文档。不会发出 EditorReplaceText 请求,因此所有后续请求都针对文件的原始状态进行。CursorInfo 请求在文件中每个标识符的开头发出,对于每个成功的请求,在同一位置为每个重构类型(本地重命名、转换为尾随闭包等)发出 SemanticRefactoring 请求,该请求报告为可用。然后,在文件中标记级别以上的每个语法结构的唯一范围上发出 RangeInfo 请求,即每个(子)表达式、模式、语句、子句、声明等。与 CursorInfo 一样,SemanticRefactoring 请求在相同位置发出,用于响应中报告为可用的每个重构类型。最后,在每个标识符和更高级别表达式的开头和结尾发送 CodeComplete 请求,然后在 EditorClose 请求释放文档之前发送。

    下面的动画可视化了小型示例文件的此过程。请注意,SemanticRefactoring 请求未显示,因为它们与 CursorInfo 和 RangeInfo 请求的位置和时间一致。

    Animated visualization of the default rewrite mode

    此策略从不修改输入 Swift 源文件,因此假设该文件可以编译,则它报告的任何故障都可能影响仅浏览和导航未修改的有效 Swift 代码的用户。这些通常是优先级更高的问题。

  2. --rewrite-mode=basic

    在此模式下,也会发送 EditorOpen 请求,但没有文件内容。而是发出 EditorReplaceText 请求,以从上到下逐个标记地引入输入 Swift 源文件的内容,在每个标记插入之前和之后,根据标记的类型以及它所属的更高级别的语法结构,发出各种语义请求。例如,CursorInfo 请求在所有标识符标记的起始位置发出,一旦它们被引入,而 CodeComplete 请求在插入标识符之前立即发送,并在插入标识符和结束表达式的标记之后立即发送。同时,RangeInfo 请求针对所有更高级别的语法结构发送,一旦它们的第一个和最后一个标记被插入。与默认模式一样,SemanticRefactoring 请求针对从 CursorInfo 和 RangeInfo 请求返回的每个可用重构发送。

    Animated visualization of the basic rewrite mode

    虽然浏览和导航有效代码很重要,但许多 sourcekitd 请求(如 CodeComplete)主要在无效、不完整的状态下的 Swift 源文件上调用。这是在具有不完整语法和无法解析的标识符的源上练习 sourcekitd 的最简单策略。

  3. --rewrite-mode=concurrent

    此模式的工作方式类似于基本模式,但就像它是为文件中每个顶级声明并发运行一样。它插入第一个顶级声明的单个标记,然后插入下一个顶级声明的标记,然后插入下一个,依此类推,以类似于循环的方式,直到所有标记都被放置。语义请求(如 CursorInfo 和 CodeComplete)在每个标记插入之前和/或之后执行,根据与上述基本模式相同的规则。

    Animated visualization of the concurrent rewrite mode

    除了产生不完整的语法外,这种方法还会导致文件中稍后的声明临时嵌套在较早的声明中,通常会给它们无效的上下文。

  4. --rewrite-mode=insideOut

    与前两种模式一样,最初发送一个没有文件内容的 EditorOpen 请求,标记通过 EditorReplaceText 请求逐渐插入。但是,这种情况下的顺序是从提供的文件的语法结构中最深层嵌套的标记到最浅层的标记。此深度基于 SwiftSyntax 的语法树,因此非常细粒度。例如,在表达式 (1-2)+3 中,标记将按以下时间顺序插入:12-()3+。除了不同的插入顺序外,此模式的工作方式与并发模式和基本模式类似,在引入标记时根据其类型及其完成的更高级别结构发送语义请求。

    Animated visualization of the insideOut rewrite mode

    这种方法在其早期阶段会导致相当难以理解的修改和文件状态,但在查找 SwiftSyntax 和最近引入的增量解析逻辑(sourcekitd 用于在 EditorOpen 和 EditorReplaceText 请求中提供语法信息)中的问题时非常有用。

对整个项目运行压力测试器:sk-swiftc-wrapper

$ sk-swiftc-wrapper <编译器参数>

压力测试器可执行文件本身对于在现有项目上运行不是很方便,因为它只能按文件运行并且需要显式的编译器参数。为了简化此操作,工具链包含 sk-swiftc-wrapper 可执行文件。这封装并充当 Swift 编译器 swiftc 的替代品。调用时,它会将给定的编译器参数传递给 swiftc 以进行正常编译,但如果编译成功,它还会对编译的每个 Swift 源文件调用压力测试器。为了加快速度,可以并行运行许多此类调用,具体取决于可用处理器的数量。如果任何这些压力测试器调用失败,则整个调用也会失败。这使得在一个项目上运行 sourcekitd 压力测试器就像设置 sk-swiftc-wrapper 作为要使用的 swift 编译器并进行构建一样简单。如果压力测试器发现问题,则构建将失败,并且有关问题的详细信息将包含在构建输出中。

通过 Swift CI 进行回归和拉取请求测试

为了帮助在引入 sourcekitd 故障时捕获它们,压力测试器现在正在 Swift 的持续集成测试中,对 Swift 源代码兼容性套件中的 78 个开源项目运行。Swift 源代码兼容性套件的目的是帮助确保 Swift 源代码在语言和编译器发展过程中的兼容性,但其 Xcode 和 Swift Package Manager 项目的混合,跨越各种领域,使其成为运行压力测试器的绝佳真实世界 Swift 代码语料库。由于运行时间较长,Swift CI 目前每周对整个套件运行一次压力测试器,并且每当 sourcekitd 和编译器发生更改时,都会对具有更快周转速度的较小子集持续运行。

到目前为止,在 Swift 源代码兼容性套件上运行已发现 91 个影响 sourcekitd 的问题,包括由压力测试器报告的早期问题的修复程序引起的几个回归。为了更容易在更改合并之前捕获此类回归,我们还添加了拉取请求测试支持,以在源代码兼容性套件的子集上运行压力测试器。Swift 项目贡献者可以通过在其 PR 上的评论中包含下面的 @swift-ci 提及,在合并之前针对他们的更改运行压力测试器

@swift-ci 请进行压力测试

迄今为止,压力测试器检测到的 91 个 sourcekitd 问题中有 72 个已得到修复。这些修复当然提高了 sourcekitd 的质量和编辑体验,但在许多情况下也改进了 Swift 编译器本身。这是因为 sourcekitd 与编译器共享许多通用代码,并且在更广泛的无效 Swift 源代码上对其进行练习。例如,代码补全通常在对一个或多个文件进行更改的过程中调用,而构建通常仅在这些更改接近完成时才触发。对于编译器而言,修复这些问题通常是获得有用的诊断信息和段错误之间的区别。

在您自己的项目中查找并报告 sourcekitd 崩溃

源代码兼容性套件中的项目是一个良好的开端,但是运行压力测试器的项目越多,它就能发现更多问题。出于这个原因,sourcekitd 压力测试器现在包含在 swift.org 主干开发工具链中。如果您从事任何 Swift 项目(如果您正在阅读此博客,您可能正在这样做),请尝试在它们上运行它,并使用以下说明报告它发现的任何故障。这不仅会改善您自己在未来版本中的 Swift 编辑体验,还会改善其他所有人的体验。

Xcode 项目

要对 Xcode 项目运行压力测试器

  1. swift.org 下载并安装最新的 Swift 工具链主干开发快照
  2. 打开 Xcode 并在菜单中通过 Xcode > Toolchains 选择下载的工具链
  3. 打开您的项目并导航到您的项目或您要进行压力测试的特定目标的 Build Settings 视图
  4. 添加用户定义的构建设置 SWIFT_EXEC,其值设置为 $(TOOLCHAIN_DIR)/usr/bin/sk-swiftc-wrapper,如下所示

    Add the SWIFT_EXEC custom build setting

  5. 开始构建 (⌘B) 您要进行压力测试的目标,并在 Report Navigator 中的构建日志中查找有关其检测到的任何问题的详细信息。压力测试 sourcekitd 是一项开销很大的操作,因此预计构建时间会比平时长得多。
  6. 如果检测到任何问题,请按照下面的提交说明进行操作。

Swift 包管理器项目

要对 Swift 包管理器项目运行压力测试器,您可以生成一个 Xcode 项目,方法是使用 swift package --generate-xcodeproj 并按照上述说明进行操作,或者使用以下说明在命令行上运行

  1. swift.org 下载并安装最新的 Swift 工具链开发快照
  2. 确定已安装工具链的 bin 目录的路径。根据您选择的安装选项,这应该在您的主目录或根目录下的 Library/Developer/Toolchains/<toolchain>/usr/bin 下。

    $ TOOLCHAIN_BIN=/Library/Developer/Toolchains/swift-DEVELOPMENT-SNAPSHOT-2019-01-21-a.xctoolchain/usr/bin

  3. 使用 build 命令调用工具链的 swift 可执行文件,并另外将 SWIFT_EXEC 环境变量设置为 sk-swiftc-wrapper 的路径。

    $ SWIFT_EXEC=$TOOLCHAIN_BIN/sk-swiftc-wrapper $TOOLCHAIN_BIN/swift build

  4. 检查命令输出以了解进度和检测到的故障。压力测试 sourcekitd 是一项开销很大的操作,因此预计构建时间会比平时长得多。
  5. 如果检测到任何问题,请参阅下面的提交说明。

提交压力测试工具发现的问题报告

当压力测试工具检测到问题时,它会在 Xcode 的构建日志或 swift build 调用命令行输出中报告有关失败的详细信息。一个典型的例子如下,这是在对 SwiftSyntax 项目进行压力测试时发现的

Detected unexpected failure: Sourcekitd crashed
  request: CursorInfo in /tmp/swift-syntax/.../ByteTreeDeserialization.swift (modified: concurrent)
    at offset 2694 with args: -incremental -module-name SwiftSyntax ...

-- begin file content --------
//===----- ByteTreeDeserialization.swift - Reading the ByteTree format ----===//
//
// This source file is part of the  open source project
//

...

/// Helper object for reading objects out a ByteTree. Keeps track that fields
/// are not read out of order and discards all trailing fields that were present
/// in the binary format but were not handled when reading the object.
struct ByteTreeObjectReader {

...

  fileprivate init(reader: UnsafeMutablePointer<ByteTreeReader>,
                   <cursor-offset>numFields

struct ByteTreeProtocolVersion {
  let major: Int
  let minor: Int
}

...
-- end file content ----------

如果压力测试工具在项目运行时检测到如上所示的意外故障,请按照以下步骤报告它

  1. 访问 bugs.swift.org,注册或登录您现有的帐户,并创建一个新 issue。
  2. 在出现的表单中,在“Summary”字段中包含检测到的故障类型和触发它的请求类型。对于上面的示例,可以填写类似“Sourcekitd crashed making a CursorInfo request”的内容。
  3. 将压力测试工具的输出从“Detected unexpected failure”行复制粘贴到“end file content”行,并粘贴到“Description”字段中。
  4. 对于“Component”,输入“Tooling”。
  5. 对于“Environment”,请务必包含您使用的 swift.org 工具链版本和(如果适用)Xcode 版本。如果可以,还请提供有关如何访问您运行压力测试工具的项目的详细信息(例如,通过提供 Git URL 进行克隆,或附加 Xcode 项目),以及重现该问题的任何步骤(例如,swift build 调用或运行压力测试工具时使用的目标和运行目的地)。
  6. 在附件字段中,如果可以,请包含项目(如上所述),但如果故障是崩溃,请同时附加 ~/Library/Logs/DiagnosticReports/SourceKitService* 下的任何最新崩溃日志。
  7. 点击“Create”按钮完成 issue 提交,并通过出现的通知或“Issues”菜单中的“Recent Issues”导航到该 issue。
  8. 在“Details”部分,请添加标签“FoundByStressTester”,以帮助我们跟踪压力测试工具正在发现的问题的数量和种类。

结论

sourcekitd 压力测试工具是一个相对简单的新型 sourcekitd 测试工具,但基于在 Swift 源代码兼容性套件上运行以及将其纳入 Swift 的 CI 测试中所发现的问题,我们预计它将对未来 Xcode 和 SourceKit-LSP 中 Swift 编辑体验的可靠性产生重大影响。了解到代码补全、本地重构和许多其他 sourcekitd 功能在 Swift 源代码兼容性套件中每个项目的每个文件的每个 token 上都能可靠地工作,这让我们更有信心相信对 sourcekitd 和编译器的更改不会使此功能倒退。压力测试工具包含在 swift.org 工具链中也为进一步扩展覆盖范围提供了一条途径,因为 Swift 开发人员现在有一种简单的方法来查找和报告他们自己项目中的 sourcekitd 故障。

疑问?

请随时在 Swift 论坛的相关帖子中发布关于此帖子的疑问。