• Stars
    star
    109
  • Rank 309,733 (Top 7 %)
  • Language
    Swift
  • License
    MIT License
  • Created over 4 years ago
  • Updated over 3 years ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

Swift Leak Checker

A framework, a command-line tool that can detect potential memory leak caused by strongly captured self in escaping closure

Example

Some examples of memory leak that are detected by the tool:

class X {
  private var handler: (() -> Void)!
  private var anotherHandler: (() -> Void)!
  
  func setup() {
    handler = {
      self.doSmth() // <- Leak
    }
    
    anotherHandler = { // Outer closure
      doSmth { [weak self] in // <- Leak
        // .....    
      }
    }
  }
}

For first leak, self holds a strong reference to handler, and handler holds a strong reference to self, which completes a retain cycle.

For second leak, although self is captured weakly by the inner closure, but self is still implicitly captured strongly by the outer closure, which leaks to the same problem as the first leak

Usage

There're 2 ways to use this tool: the fastest way is to use the provided SwiftLeakChecker target and start detecting leaks in your code, or you can drop the SwiftLeakCheck framework in your code and start building your own tool

SwiftLeakChecker

There is a SwiftLeakChecker target that you can run directly from XCode or as a command line.

To run from XCode:

Edit the SwiftLeakChecker scheme and change the /path/to/your/swift/file/or/folder to an absolute path of a Swift file or directory. Then hit the Run button (or CMD+R)

To run from command line:

./SwiftLeakChecker path/to/your/swift/file/or/folder

Build your own tool

The SwiftLeakChecker target is ready to be used as-is. But if you want to build your own tool, do more customisation etc.., then you can follow these steps.

Note: Xcode 11 or later or a Swift 5.2 toolchain or later with the Swift Package Manager is required.

Add this repository to the Package.swift manifest of your project:

// swift-tools-version:4.2
import PackageDescription

let package = Package(
  name: "MyAwesomeLeakDetector",
  dependencies: [
    .package(url: "This repo .git url", .exact("package version")),
  ],
  targets: [
    .target(name: "MyAwesomeLeakDetector", dependencies: ["SwiftLeakCheck"]),
  ]
)

Then, import SwiftLeakCheck in your Swift code

To create a leak detector and start detecting:

import SwiftLeakCheck

let url = URL(fileURLWithPath: "absolute/path/to/your/swift/file/or/folder")
let detector = GraphLeakDetector()
let leaks = detector.detect(url)
leaks.forEach { leak in
  print("\(leak)")
}

Leak object

Each Leak object contains line, column and reason info.

{
  "line":41,
  "column":7,
  "reason":"`self` is strongly captured here, from a potentially escaped closure."
}

CI and Danger

The image on top shows a leak issue that was reported by our tool running on Gitlab CI. We use Danger to report the line and reason of every issue detected.

How it works

We use SourceKit to get the AST representation of the source file, then we travel the AST to detect for potential memory leak. Currently we only check if self is captured strongly in an escaping closure, which is one specific case that causes memory leak

To do that, 3 things are checked:

1. Check if a reference captures self

block { [weak self] in
  guard let strongSelf = self else { return }
  let x = SomeClass()
  strongSelf.doSmth { [weak strongSelf] in
    guard let innerSelf = strongSelf else { return }
    x.doSomething()
  }
}

In this example, innerSelf captures self, because it is originated from strongSelf which is originated from self

x is also a reference but doesn't capture self

2. Check if a closure is non-escaping

We use as much information about the closure as possible to determine if it is non-escaping or not.

In the example below, block is non-escaping because it's not marked as @escaping and it's non-optional

func doSmth(block: () -> Void) {
   ... 
}

Or if it's anonymous closure, it's non-escaping

let value = {
  return self.doSmth()
}()

We can check more complicated case like this:

func test() {
  let block = {
    self.xxx
  }
  doSmth(block)
}
func doSmth(_ block: () -> Void) {
  ....
}

In this case, block is passed to a function doSmth and is not marked as @escaping, hence it's non-escaping

3. Whether an escaping closure captures self stronlgy from outside

block { [weak self] in
  guard let strongSelf = self else { return }
  self?.doSmth {
    strongSelf.x += 1
  }
}

In this example, we know that:

  1. strongSelf refers to self
  2. doSmth is escaping (just for example)
  3. strongSelf (in the inner closure) is defined from outside, and it captures self strongly

False-positive alarms

If we can't determine if a closure is escaping or non-escaping, we will just treat it as escaping.

It can happen when for eg, the closure is passed to a function that is defined in other source file. To overcome that, you can define custom rules which have logic to classify a closure as escaping or non-escaping.

Non-escaping rules

By default, we already did most of the legworks trying to determine if a closure is non-escaping (See #2 of How it works section)

But in some cases, there's just not enough information in the source file. For eg, we know that a closure passed to DispatchQueue.main.async will be executed and gone very soon, hence it's safe to treat it as non-escaping. But the DispatchQueue code is not defined in the current source file, thus we don't have any information about it.

The solution for this is to define a non-escaping rule. A non-escaping rule is a piece of code that takes in a closure expression and tells us whether the closure is non-escaping or not. To define a non-escaping rule, extend from BaseNonEscapeRule and override func isNonEscape(arg: FunctionCallArgumentSyntax,....) -> Bool

Here's a rule that matches DispatchQueue.main.async or DispatchQueue.global(qos:).asyncAfter :

open class DispatchQueueRule: NonEscapeRule {
  
  open override isNonEscape(arg: FunctionCallArgumentSyntax?, funcCallExpr: FunctionCallExprSyntax,, graph: Graph) -> Bool {
    // Signature of `async` function
    let asyncSignature = FunctionSignature(name: "async", params: [
      FunctionParam(name: "execute", isClosure: true)
    ])
    
    // Predicate to match DispatchQueue.main
    let mainQueuePredicate = ExprSyntaxPredicate.memberAccess("main", base: ExprSyntaxPredicate.name("DispatchQueue"))
    
    let mainQueueAsyncPredicate = ExprSyntaxPredicate.funcCall(asyncSignature, base: mainQueuePredicate)
    if funcCallExpr.match(mainQueueAsyncPredicate) { // Matched DispatchQueue.main.async(...)
        return true
    }
    
    // Signature of `asyncAfter` function
    let asyncAfterSignature = FunctionSignature(name: "asyncAfter", params: [
      FunctionParam(name: "deadline"),
      FunctionParam(name: "execute", isClosure: true)
    ]) 
    
    // Predicate to match DispatchQueue.global(qos: ...) or DispatchQueue.global()
    let globalQueuePredicate = ExprSyntaxPredicate.funcCall(
      FunctionSignature(name: "global", params: [
        FunctionParam(name: "qos", canOmit: true)
        ]),
      base: ExprSyntaxPredicate.name("DispatchQueue")
    )
    
    let globalQueueAsyncAfterPredicate = ExprSyntaxPredicate.funcCall(asyncAfterSignature, base: globalQueuePredicate)
    if funcCallExpr.match(globalQueueAsyncAfterPredicate) {
        return true
    }
    
    // Doesn't match either function
    return false
  }
}

Here's another example of rule that matches UIView.animate(withDurations: animations:):

open class UIViewAnimationRule: BaseNonEscapeRule {
  open override func isNonEscape(arg: FunctionCallArgumentSyntax?, funcCallExpr: FunctionCallExprSyntax, graph: Graph) -> Bool {
    let signature = FunctionSignature(name: "animate", params: [
      FunctionParam(name: "withDuration"),
      FunctionParam(name: "animations", isClosure: true)
      ])
    
    let predicate = ExprSyntaxPredicate.funcCall(signature, base: ExprSyntaxPredicate.name("UIView"))
    return funcCallExpr.match(predicate)
  }
}

After creating the non-escaping rule, pass it to the leak detector:

let leakDetector = GraphLeakDetector(nonEscapingRules: [DispatchQueueRule(), UIViewAnimationRule()])

Predefined non-escaping rules

There're some ready-to-be-used non-escaping rules:

1. DispatchQueueRule

We know that a closure passed to DispatchQueue.main.async or its variations is escaping, but the closure will be executed very soon and destroyed after that. So even if it holds a strong reference to self, the reference will be gone quickly. So it's actually ok to treat it as non-escaping

3. UIViewAnimationRule

UIView static animation functions. Similar to DispatchQueue, UIView animation closures are escaping but will be executed then destroyed quickly.

3. UIViewControllerAnimationRule

UIViewController's present/dismiss functions. Similar to UIView animation rule.

4. CollectionRules

Swift Collection map/flatMap/compactMap/sort/filter/forEach. All these Swift Collection functions take in a non-escaping closure

Write your own detector

In case you want to make your own detector instead of using the provided GraphLeakDetector, create a class that extends from BaseSyntaxTreeLeakDetector and override the function

class MyOwnLeakDetector: BaseSyntaxTreeLeakDetector {
  override func detect(_ sourceFileNode: SourceFileSyntax) -> [Leak] {
    // Your own implementation
  }
}

// Create an instance and start detecting leaks
let detector = MyOwnLeakDetector()
let url = URL(fileURLWithPath: "absolute/path/to/your/swift/file/or/folder")
let leaks = detector.detect(url)

Graph

Graph is the brain of the tool. It processes the AST and give valuable information, such as where a reference is defined, or if a closure is escaping or not. You probably want to use it if you create your own detector:

let graph = GraphBuilder.buildGraph(node: sourceFileNode)

Note

  1. To check a source file, we use only the AST of that file, and not any other source file. So if you call a function that is defined elsewhere, that information is not available.

  2. For non-escaping closure, there's no need to use self.. This can help to prevent false-positive

License

This library is available as open-source under the terms of the MIT License.

More Repositories

1

front-end-guide

๐Ÿ“š Study guide and introduction to the modern front end stack.
JavaScript
14,933
star
2

cocoapods-binary-cache

Ruby
451
star
3

cocoapods-pod-merge

Cocoapods plugin to merge pods used by your Xcode project, reducing the number of dynamic frameworks your app has to load on startup
Ruby
363
star
4

Grazel

A tool to migrate Android projects from Gradle to Bazel incrementally and automatically
Kotlin
246
star
5

engineering-blog

๐Ÿ“ We write about our technologies and the problems we handle at scale.
Ruby
119
star
6

talaria

TalariaDB is a distributed, highly available, and low latency time-series database for Presto
Go
108
star
7

secret-scanner

Go
40
star
8

grab-bazel-common

Common rules and macros for Grab's Android projects built with Bazel.
Kotlin
35
star
9

hackathon

๐Ÿ’ป Official Grabathon websites
JavaScript
29
star
10

symphony

Go
28
star
11

grabplatform-sdk-js

GrabPlatform SDK in javascript
TypeScript
27
star
12

async

Go
24
star
13

grabplatform-sdk-android

GrabPlatform SDK for android
Kotlin
20
star
14

superapp-sdk

SDK for Grab SuperApp WebView.
JavaScript
15
star
15

grabplatform-sdk-ios

GrabPlatform SDK for iOS
Swift
12
star
16

grab-query-traces

10
star
17

grabpay-merchant-sdk

Java
9
star
18

gosm

Gosm is a golang library which implements writing OSM pbf files.
Go
9
star
19

mobile-kit-bridge-sdk

SDK for web view bridges that offers unified method signatures for Android/iOS
TypeScript
7
star
20

GraphBEAN

Interaction-Focused Anomaly Detection on Bipartite Node-and-Edge-Attributed Graphs
Python
6
star
21

grabplatform-sample

Comprehensive sample for GrabPlatform-related SDKs.
JavaScript
4
star
22

blogs

Accompanying source code for our engineering blog
Ruby
4
star
23

grabplatform-sdk-golang

GrabPlatform SDK for Golang
3
star
24

grabplatform-sdk-js-example

JavaScript
1
star
25

go-showdeps

Go
1
star