Skip to content

false-positive clang-analyzer-cplusplus.NewDeleteLeaks on allocation owned by temporary initialized by a default argument #149236

@puetzk

Description

@puetzk
#include <new>

struct foo {
	~foo() { delete m_buf; }

	char *get_lazy() noexcept [[clang::lifetimebound]] { 
		if(!m_buf) { m_buf = new (std::nothrow) char; }
		return static_cast<char *>(m_buf);
	}
	char *m_buf = nullptr;
};

char *not_leaky(foo &&buf [[clang::lifetimebound]] = {}) noexcept {
	return buf.get_lazy();
}

int main() {
#if 1
	not_leaky(); // warning: Potential leak of memory pointed to by field 'm_buf' [clang-analyzer-cplusplus.NewDeleteLeaks] 
#else
	not_leaky({}); // making the temporary explicit (instead of defaulted) fixes the false-positive
#endif
}

This should not contain any leaks - get_lazy() does perform an allocation, but foo::m_buf takes ownership of it, and ~foo frees it.

However, with #if 1 clang-analyzer-cplusplus.NewDeleteLeaks reports a perceived leak

<source>:21:1: warning: Potential leak of memory pointed to by field 'm_buf' [clang-analyzer-cplusplus.NewDeleteLeaks]
   21 | }
      | ^
<source>:17:2: note: Calling 'not_leaky'
   17 |         not_leaky(); // warning: Potential leak of memory pointed to by field 'm_buf' [clang-analyzer-cplusplus.NewDeleteLeaks] 
      |         ^~~~~~~~~~~
<source>:12:9: note: Calling 'foo::get_lazy'
   12 |         return buf.get_lazy();
      |                ^~~~~~~~~~~~~~
<source>:5:6: note: Assuming field 'm_buf' is null
    5 |                 if(!m_buf) { m_buf = new char; }
      |                    ^~~~~~
<source>:5:3: note: Taking true branch
    5 |                 if(!m_buf) { m_buf = new char; }
      |                 ^
<source>:5:24: note: Memory is allocated
    5 |                 if(!m_buf) { m_buf = new char; }
      |                                      ^~~~~~~~
<source>:12:9: note: Returned allocated memory
   12 |         return buf.get_lazy();
      |                ^~~~~~~~~~~~~~
<source>:17:2: note: Returned allocated memory
   17 |         not_leaky(); // warning: Potential leak of memory pointed to by field 'm_buf' [clang-analyzer-cplusplus.NewDeleteLeaks] 
      |         ^~~~~~~~~~~
<source>:21:1: note: Potential leak of memory pointed to by field 'm_buf'
   21 | }
      | ^

See https://godbolt.org/z/n9hc5Pdxh, reproducing it on clang trunk with "clang version 22.0.0git (https://github.com/llvm/llvm-project.git 82d7405)"

If you switch to #if 0 (i.e., switch from not_leaky() to not_leaky({}), making the temporary explicitly represented in the source code instead of being a defaulted argument, the clang-analyzer-cplusplus.NewDeleteLeaks warnings go away.

The generated assembly is identical either way, showing main does call the destructor, which does call delete. So I don't see any way the potential leak can be real.

        mov     qword ptr [rbp - 8], 0
        lea     rdi, [rbp - 8]
        call    not_leaky(foo&&)
        lea     rdi, [rbp - 8]
        call    foo::~foo() [base object destructor]

I used std::nothrow new and noexcept just to simplify the control flow so there wouldn't be `std::bad_alloc exception paths cluttering up the assembler, but it the false-positive is the same regardless.

I have also included [[clang::lifetimebound]] annotations because that's what I was doing to the "real" code I reduced this example from, but they also aren't actually necessary to reproduce the NewDeleteLeaks report. However, the seemingly spurious dependency on whether the temporary argument is defaulted or explicitly passed certainly suggests it might be something similar to #68596 / #112047, with the analyzer not following the lifetime of temporary buried inside CXXDefaultArgExpr...

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions