The question is did we introduce undefined behaviour that trips optimizer, or can we file a bug report against gcc?
Sorry for lack of better title, but it's quite fragile and we are almost sure that this is a bug. The minimal example is not our favourite design but it's based on production code that crashed:
#include <iostream>
struct Node
{
Node(Node* parent) {
if(parent) {
parent->child_ = this;
}
}
Node* child()
{
return child_;
}
Node* child_ = nullptr;
};
void walk(Node* module, int cleanup) {
if(module != nullptr) {
if(!cleanup) {
std::cerr << "No cleanup";
}
walk(module->child(), cleanup);
if(cleanup) {
delete module;
}
}
}
int main (){
Node* top = new Node(nullptr);
Node* child = new Node(top);
walk(top,1);
}
Compiled with -O1 -foptimize-sibling-calls -ftree-vrp. Godbolt example: https://gcc.godbolt.org/z/4VijKb
Program crashes calling module->child() when module is 0x0. Inspecting assembler we noticed that if (module != nullptr) is skipped at the beginning of walk. There is a check for cleanup and call to work seems to be unconditional, which results in trying to pull child_ from an invalid pointer.
The check is reestablished in assembly (and code seems to work) if:
- Any of the two optimizations over
-O1is taken away. - Body of
if(!cleanup)is removed. (No side effect fromcerr) - Body of
if(cleanup)is removed. (Memory leak, but I think it counts as observable behaviour change) walkis called before "No cleanup"if. (Operation order)cleanuptype is changed toboolfrom anint. (Type change - but no observable behaviour change, I think).- Insertion of unconditional
cerr << "text";before andif(!cleanup). (Also an observable change.)
It seems like a weird combination of tail-recursion and nullptr check removal that resulted in wrong code. Possibly walk got split into sibling functions based on cleanup checks and got stitched wrongly(?).
Two candidates for UB were:
- Hinting compiler that
moduleis non-nullptr, but I don't see a way compiler could infer the result. - Using
intinboolcontext, but it's legal AFAIK.
FWIW clang seems to produce correct run-time, gcc 8.3 also has the assembly for the check present. 9.1 and trunk not. We don't have any gcc expert at hand, so we had no idea why optimizer could be misled.