New Issue: Return type inference + function body resolution induces order-dependent compilation failures

27140, "DanilaFe", "Return type inference + function body resolution induces order-dependent compilation failures", "2025-04-22T19:15:34Z"

This issue affects both production and Dyno, though the motivating case is a piece of standard library code that only fails in Dyno.

Consider the following program:

proc bar(x): int {
  var unused = foo(x);
  return 42;
}

proc foo(x) {
  return bar(x);
}

foo(true);

Here we have two generic functions, foo and bar. The return type of foo is left unspecified, and to infer it, we need to determine the return type of bar. The return type of bar, on the other hand, is specified to be int. This means that we do not need to resolve its body to compute the return type.

There are two separate reasons why the functions in the above program need to be recursively resolved. Neither of these ought to produce errors: the program is well-formed. They are as follows:

  • Return type resolution. As I mentioned in the preceding paragraph, foo's body needs to be resolved to compute its return type. The fact that a call is present in the body of bar does not mean the program is ill-formed. This is because we specifically allow dependency cycles to be broken by an explicitly-specified return type. For example, the following program resolves without problem:

    proc bar(x): int {
      return foo(x);
    }
    
    proc foo(x): int {
      return bar(x);
    }
    
    foo(true);
    

    So, we have recursion, but it's terminated by an explicitly-specified return type, in both Dyno and production.

  • Function body resolution. I couldn't give you all the reasons that production needs to resolve a called function's body. Among those reasons are issuing compilerErrors and computing information for POI caching. Dyno shares the two reasons I gave here. The fact that the program is mutually recursive does not make this analysis ill-formed. The following program resolves and prints the two warnings as expected:

    proc bar(x) {
      foo(x);
      compilerWarning(x.type : string);
      return 42;
    }
    
    proc foo(x) {
      bar(x);
      compilerWarning(x.type : string);
      return 42;
    }
    
    foo(true);
    

    So we have recursion, but it's terminated by checking if a function is already being resolved (FLAG_RESOLVED in production, isQueryRunning in Dyno).

The catch, however, is that for each function, both of the reasons to resolve bodies are present. Thus, while it might have a specified return type, we might resolve its body for the POI information, and while we're resolving for POI information, we might go right back to resolving the return types. As a result, unless both termination conditions are met, we might hit infinite recursion. In reproducer at the top of the issue:

  1. We start to resolve foo's return type
  2. Since foo doesn't have a return type, we must resolve its body
  3. We start to resolve bar's return type
  4. Since bar has a return type, we don't need to resolve its body.
  5. Back in foo, we now start to resolve bar for the purposes of POI etc, entering bar's body.
  6. Inside bar, there's a call to foo. To compute POI etc., we need to determine its return type.
  7. We start to resolve foo's return type
  8. Since foo doesn't have a return type, we must resolve its body

This leads to a cycle. Fundamentally, the problem is that, whereas recursion would've terminated at step 4 for computing types, we continued recursing in 5 for a different purpose.

Notably, starting the cycle at bar prevents errors. In that case,

  1. We start to resolve bar's return type
  2. Since bar has a return type, we don't need to resolve its body.
  3. Back in main, we now start to resolve bar for the purposes of POI etc, entering bar's body.
  4. Inside bar, there's a call to foo. To compute POI etc., we need to determine its return type.
  5. We start to resolve foo's return type
  6. Since foo doesn't have a return type, we must resolve its body
  7. We start to resolve bar's return type
  8. Since bar has a return type, we don't need to resolve its body.
  9. We finish computing foo's return type.
  10. Back in bar, we now start to resolve foo for the purposes of POI etc, entering foo's body.
  11. We start to resolve bar's return type. It has been computed, nothing to do.
  12. Back in foo, we now start to resolve bar for the purposes of POI etc. We would enter bar's body, but it's currently being resolved, so we stop.
  13. We're done with foo.
  14. We're done with bar.

thus, inserting a call to bar before the call to foo fixes the issue in both production and Dyno. The motivator (bufferCopyLocal in ByteBufferHelpers) only occurs in Dyno because the order in which Dyno resolves the stdlib is sufficiently different to production's order. However, in effect, production is relying on the order-dependence demonstrated by the two above step-by-step examples to avoid hitting the error. In other words, there is no principled solution in either resolver.