use_build_context_synchronously design

Background

The idea behind the use_build_context_synchronously lint rule requires careful tracking of a function body's possible control flow. At various points in the syntax tree, we must be able to answer questions like, “when this expression is reached, is it possible that we have traversed through an asynchronous gap?” and “does this mounted check definitely guard this expression?” The intricacies of such tracking are quite different from the requirements of most linter rules.

High level design

At a high level, the task of the lint rule can be broken down into three steps:

  1. Consider each expression which references a BuildContext value, and which may be vulnerable to async gap bugs. For each such expression:
  2. Walk up the syntax tree from the expression, until reaching the boundary of a function body (don't walk outside an anonymous function body, or a method declaration, etc.). At each ancestor node (well, not exactly ancestor, see below):
  3. Compute the “async state” between the ancestor node and the expression-in- question, by visiting the ancestor's descendent nodes, looking for await expressions and mounted checks.

The first step just uses the linter's standard rule-registering mechanism. The latter steps are explained below.

Mounted guards

It would be egregious to require a function with a reference to a BuildContext to have zero async gaps (await expressions) above the reference. Developers are allowed a safeguard: a “mounted check” which leads to a “mounted guard.”

  • A “mounted check” is merely an expression in which the mounted property of a BuildContext expression is read. It typically looks like context.mounted.
  • A mounted check leads to a “mounted guard” for a certain node if it guarantees that the node will only be executed if the BuildContext is mounted, that is, the mounted check has returned positive.

Walking up the ancestors and their preceding siblings

In this step, we have a single expression with a reference to a BuildContext object, and we need to examine nodes which contain an async gap which could be crossed before the access to the reference. This means we are simulating, to a very limited extent, the runtime flow between an async gap and reference.

  1. Start with the expression containing the reference.
  2. Define a child node whose value is initially reference.
  3. While child is not a function body: a. Set a parent node equal to the child's parent. b. Check the “async state” between parent and child. c. If the state is “asynchronous,” then there is a possible async gap between parent and child, so report a lint. d. If the state is “mounted guard,” then there is a definite mounted guard between parent and child, so child is safe, and we can stop checking child.

Note: We use child in the loop, rather than just reference, in order to make use of child's relationship to parent, when computing the “async gap” (see the next section).

Given the way we walk up the syntax tree, the async state between parent and child is the same state as between parent and reference: if there is a possible async gap between parent and child, then there is a possible async gap between parent and reference, and if there is a definite mounted guard between parent and child, then there is a definite mounted guard between parent and reference.

Computing the “async state” of one node relative to another

This is the most complex and delicate step. Given two nodes, a parent and child, we must calculate whether there is a possible async gap between the two (with no mounted guard between the async gap and the child), or a definite mounted guard between the two (without a possible async gap between the mounted guard and child), or no interesting async state between the two. This calculation is based on a few simple properties:

  • We implement this calculation with a standard SimpleVisitor from the analyzer. We create the visitor with child as the reference node to consider. Each visit method descends down various child nodes, receiving their returned “async state” in order to calculate the state of it's own visited node, relative to child.
  • At the entrypoint of the visitation, child's parent is parent. Then we descend into parent's various descendents, and so for every other visit method, child is a sibling of the visited node (in a NodeList), or is a child of some ancestor of the visited node.
  • For nodes with multiple children, the associated visit method must take the position of child into account, as an async gap only affects child if it occurs before child. Examples:
    • YieldStatement - this node has one child. If child is that child, then the async state is “uninteresting” (null). Otherwise, child follows the YieldStatement, and any await expressions occurring in the YieldStatement's expression result in AsyncState.asynchronous.
    • Block - this node has a list of child Statements. If child is one of those, then we compute the async state between each Statement that precedes child and child. We do not consider the Statements that follow child (unless the parent of the Block is a DoStatement, ForStatement, or WhileStatement). We consider each preceding Statement in reverse order, starting with the Statement that immediately precedes child. In this way we can correctly identify that a Statement which acts as a mounted guard for child. If child is not one of the Statements, then it follows the Block, and any await expressions occurring in the Block's expressions result in AsyncState.asynchronous.
    • MethodInvocation - this node has a target Expression and an argument list of Expressions. At runtime, the target is evaluated before the argument list, and the arguments are evaluated in left-to-right order. The associated visit method uses these facts to compute, for example, that if child is the target, then any async gaps in the argument list do not affect it.
  • For nodes which can affect control flow (IfStatement, IfElement, ConditionalExpression, etc.), the associated visit methods must take child's position into account for the purposes of mounted guards. For example, an IfStatement with a condition like context.mounted guards the then-statement, but not the else-statement, and no statements that follow the IfStatement. An IfStatement with a condition like !context.mounted and a then-statement that definitely exits (e.g. with a return or a throw), definitely guards the statements that follow it.