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 ofbar
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
compilerError
s 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:
- We start to resolve
foo
's return type - Since
foo
doesn't have a return type, we must resolve its body - We start to resolve
bar
's return type - Since
bar
has a return type, we don't need to resolve its body. - Back in
foo
, we now start to resolvebar
for the purposes of POI etc, enteringbar
's body. - Inside bar, there's a call to
foo
. To compute POI etc., we need to determine its return type. - We start to resolve
foo
's return type - 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,
- We start to resolve
bar
's return type - Since
bar
has a return type, we don't need to resolve its body. - Back in
main
, we now start to resolvebar
for the purposes of POI etc, enteringbar
's body. - Inside bar, there's a call to
foo
. To compute POI etc., we need to determine its return type. - We start to resolve
foo
's return type - Since
foo
doesn't have a return type, we must resolve its body - We start to resolve
bar
's return type - Since
bar
has a return type, we don't need to resolve its body. - We finish computing
foo
's return type. - Back in
bar
, we now start to resolvefoo
for the purposes of POI etc, enteringfoo
's body. - We start to resolve
bar
's return type. It has been computed, nothing to do. - Back in
foo
, we now start to resolvebar
for the purposes of POI etc. We would enterbar
's body, but it's currently being resolved, so we stop. - We're done with
foo
. - 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.