Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Doing malloc(0) or not doing malloc(0)? #2980

Open
illwieckz opened this issue May 2, 2024 · 9 comments
Open

Doing malloc(0) or not doing malloc(0)? #2980

illwieckz opened this issue May 2, 2024 · 9 comments
Labels
T-Question for questions on how things works on on how to proceed on an issue

Comments

@illwieckz
Copy link
Member

illwieckz commented May 2, 2024

This topic surfaced multiple times these days.

The stage iteration case

At first when implementing this PR I basically did this:

if ( numStages )
{
	const size_t stageListSize = sizeof( shaderStage_t ) * numStages;
	newShader->stages = (shaderStage_t*) ri.Hunk_Alloc( stageListSize, ha_pref::h_low );
	memcpy( newShader->stages, stages.data(), stageListSize );
	newShader->lastStage = newShader->stages + numStages;
}
else
{
	newShader->lastStage = newShader->stages = nullptr;
}

@slipher made the remark that I could just do:

const size_t stageListSize = sizeof( shaderStage_t ) * numStages;
newShader->stages = (shaderStage_t*) ri.Hunk_Alloc( stageListSize, ha_pref::h_low );
memcpy( newShader->stages, stages.data(), stageListSize );
newShader->lastStage = newShader->stages + numStages;

And it is true that all of this would still work even when numStages is equal to 0:

bool hasStage = newShader->stages != newShader->lastStage;
size_t stageCount = newShader->lastStage - newShader->stages;
for ( shaderStage_t s = newShader->stages; s < newShader->lastStage; s++ ) { do( s ); }

We can actually run malloc or memcpy with 0 size, the code may still work if properly written.

The empty vector sending message case

We discovered that with recent Clang, serializing an empty vector when sending a message crashes the game.

It's likely a compiler bug, but we have to deal with it and then we now attempt to prevent creating empty vectors that would be serialized.

Some profilers may assume malloc(0) is a mistake

These days I extended my testing build script to support more debuggers and profilers.

I toyed again with Electric Fence, which is a memory profiler that helped me a lot 10 years ago when debugging XQF, so out of curiosity I tried again…

So, I was surprised that by default, Electric Fence stops execution of the instrumented program on malloc(0) and prints:

Allocating 0 bytes, probably a bug.

This check can be intentionnally removed by setting EF_ALLOW_MALLOC_0=1 in environment. But I find interesting that some profilers may assume malloc(0) is probably a mistake and would be surprising to do on purpose.

I guess doing malloc(0) on purpose makes it harder if not impossible to check for this to happen by mistake in other parts of the code.

Others do it anyway

The reason why I noticed Electic Fence catches malloc(0) as error by default is that because others do it:

ElectricFence Aborting: Allocating 0 bytes, probably a bug.

#22 0x00005edc47bdaa8e in GLimp_CreateWindow (fullscreen=false, bordered=false, configuration=...)
  at Unvanquished/daemon/src/engine/sys/sdl_glimp.cpp:726

It first thought it was SDL fault:

$ head -n726 Unvanquished/daemon/src/engine/sys/sdl_glimp.cpp | tail -n1
	window = SDL_CreateWindow( CLIENT_WINDOW_TITLE, x, y, glConfig.vidWidth, glConfig.vidHeight, flags );

But in fact it is happening in Mesa:

#7  0x00007f48c82902b3 in glLabelObjectEXT () from /lib/x86_64-linux-gnu/libGLX_mesa.so.0

So, even if we would make efforts to never do malloc(0), we would have to disable the check because of others doing it anyway.

The question

I don't think it's a bad idea to prevent doing malloc(0), but I'm not sure we need to enforce that.

Even if it may not be incorrect to do it, this may ask for trouble for various reasons. For example if some profilers reports them as potential bugs by default we may assume many developpers avoid to do this, and that would mean doing malloc(0) would be poorly tested.

What is your opinion on this?

@illwieckz illwieckz added the T-Question for questions on how things works on on how to proceed on an issue label May 2, 2024
@illwieckz illwieckz changed the title Doing malloc(0) or not doing malloc(0) Doing malloc(0) or not doing malloc(0)? May 2, 2024
@illwieckz
Copy link
Member Author

I said SDL in the previous comment, but in fact it's Mesa doing it.

But not only Mesa does it, but NaCL does it too:

ElectricFence Aborting: Allocating 0 bytes, probably a bug.

#2  0x00007d4479e1e98c in NaClReceiveDatagram (handle=10, message=0x7d4478e16820, flags=0) at Unvanquished/daemon/libs/nacl/native_client/src/shared/imc/linux/nacl_imc.cc:168
#3  0x00007d4479b2fdc0 in IPC::InternalRecvMsg (handle=10, reader=...) at Unvanquished/daemon/src/common/IPC/Primitives.cpp:380
#4  0x00007d4479b30492 in IPC::Socket::RecvMsg (this=0x7d447efa2d40 <VM::rootChannel>) at Unvanquished/daemon/src/common/IPC/Primitives.cpp:470
#5  0x00007d4479aa9944 in IPC::Channel::RecvMsg (this=0x7d447efa2d40 <VM::rootChannel>) at Unvanquished/daemon/src/common/IPC/Channel.h:111

@illwieckz
Copy link
Member Author

illwieckz commented May 3, 2024

I did that:

diff --git a/src/common/System.cpp b/src/common/System.cpp
index 55cd0a036..8d35d02f6 100644
--- a/src/common/System.cpp
+++ b/src/common/System.cpp
@@ -427,11 +427,20 @@ void GenRandomBytes(void* dest, size_t size)
 
 } // namespace Sys
 
+#include <cstdio>
+
 #ifndef USING_ADDRESS_SANITIZER
 // Global operator new/delete override to not throw an exception when out of
 // memory. Instead, it is preferable to simply crash with an error.
 void* operator new(size_t n)
 {
+	if (!n)
+	{
+		Log::Warn("malloc(0) attempted\n");
+	}
+
+	ASSERT(n);
+
 	void* p = malloc(n);
 	if (!p)
 		Sys::Error("Out of memory");

And even the LLVM backend of the Mesa radeonsi driver does malloc(0):

Warn: malloc(0) attempted
Warn: Assertion failure at Unvanquished/daemon/src/common/System.cpp:442 (operator new): "n" is false 
Thread 7 "daemon:shlo0" received signal SIGTRAP, Trace/breakpoint trap.

0x0000587d7f5d399d in operator new (n=0) at Unvanquished/daemon/src/common/System.cpp:442
442		ASSERT(n);

Thread 7 (Thread 0x7ec63cbfc6c0 (LWP 3128045) "daemon:shlo0"):
#0  0x0000587d7f5d399d in operator new (n=0) at Unvanquished/daemon/src/common/System.cpp:442
#1  0x00007ec6416a8f6a in llvm::User::allocHungoffUses(unsigned int, bool) () from /lib/x86_64-linux-gnu/libLLVM-15.so.1
#2  0x00007ec6429e5231 in llvm::MemorySSA::createMemoryPhi(llvm::BasicBlock*) () from /lib/x86_64-linux-gnu/libLLVM-15.so.1
#3  0x00007ec6429e33dc in llvm::MemorySSA::buildMemorySSA(llvm::BatchAAResults&) () from /lib/x86_64-linux-gnu/libLLVM-15.so.1
#4  0x00007ec6429e2bca in llvm::MemorySSA::MemorySSA(llvm::Function&, llvm::AAResults*, llvm::DominatorTree*) () from /lib/x86_64-linux-gnu/libLLVM-15.so.1
#5  0x00007ec6429e9643 in llvm::MemorySSAWrapperPass::runOnFunction(llvm::Function&) () from /lib/x86_64-linux-gnu/libLLVM-15.so.1
#6  0x00007ec6416523c2 in llvm::FPPassManager::runOnFunction(llvm::Function&) () from /lib/x86_64-linux-gnu/libLLVM-15.so.1
#7  0x00007ec641659a63 in llvm::FPPassManager::runOnModule(llvm::Module&) () from /lib/x86_64-linux-gnu/libLLVM-15.so.1
#8  0x00007ec641652f66 in llvm::legacy::PassManagerImpl::run(llvm::Module&) () from /lib/x86_64-linux-gnu/libLLVM-15.so.1
#9  0x00007ec6415a3fad in LLVMRunPassManager () from /lib/x86_64-linux-gnu/libLLVM-15.so.1

@illwieckz
Copy link
Member Author

illwieckz commented May 3, 2024

When running llvmpipe driver with LIBGL_ALWAYS_SOFTWARE=1, I can play a game and not hit the malloc(0) assert in new.

But if I do that:

diff --git a/src/engine/client/hunk_allocator.cpp b/src/engine/client/hunk_allocator.cpp
index d42d85511..6b21ac8e7 100644
--- a/src/engine/client/hunk_allocator.cpp
+++ b/src/engine/client/hunk_allocator.cpp
@@ -222,6 +222,13 @@ Allocate permanent (until the hunk is cleared) memory
 */
 void           *Hunk_Alloc( int size, ha_pref)
 {
+	if (!size)
+	{
+		Log::Warn("Hunk_Alloc(0) attempted\n");
+	}
+
+	ASSERT(size);
+
 	void *buf;
 
 	if ( s_hunkData == nullptr )

The first occurrence I catch is the one from the exact same code my PR modifies, but before my PR is even merged!:

Warn: Hunk_Alloc(0) attempted
Warn: Assertion failure at Unvanquished/daemon/src/engine/client/hunk_allocator.cpp:230 (Hunk_Alloc): "size" is false 
Thread 1 "daemon" received signal SIGTRAP, Trace/breakpoint trap.

0x0000646ebaf5a6ac in Hunk_Alloc (size=0) at Unvanquished/daemon/src/engine/client/hunk_allocator.cpp:230
230		ASSERT(size);

Thread 1 (Thread 0x7a2a66965a80 (LWP 3157732) "daemon"):
#0  0x0000646ebaf5a6ac in Hunk_Alloc (size=0) at Unvanquished/daemon/src/engine/client/hunk_allocator.cpp:230
#1  0x0000646ebb09be16 in MakeShaderPermanent () at Unvanquished/daemon/src/engine/renderer/tr_shader.cpp:5499
#2  0x0000646ebb09cbb2 in FinishShader () at Unvanquished/daemon/src/engine/renderer/tr_shader.cpp:5903
$ head -n5499 Unvanquished/daemon/src/engine/renderer/tr_shader.cpp | tail -n1
			newShader->stages[ i ]->bundle[ b ].texMods = (texModInfo_t*) ri.Hunk_Alloc( size, ha_pref::h_low );

@illwieckz
Copy link
Member Author

illwieckz commented May 3, 2024

OK, this one seems to be legit in Unvanquished/daemon/src/engine/renderer/tr_bsp.cpp:6933:

startMarker = (byte*) ri.Hunk_Alloc( 0, ha_pref::h_low );

🤣️

There is also in Unvanquished/daemon/src/engine/renderer/tr_bsp.cpp:7000:

s_worldData.dataSize = ( byte * ) ri.Hunk_Alloc( 0, ha_pref::h_low ) - startMarker;

🤪️

But this can be a dedicated ri.Hunk_GetMarker() function.

@illwieckz
Copy link
Member Author

illwieckz commented May 3, 2024

I actually have a branch that runs without hitting an ASSERT( size ) in Hunk_Alloc().

The only three files I had to add checks for not calling Hunk_Alloc(0) were tr_shader.cpp, tr_bsp.cpp and tr_anim_md3.cpp.

So this doesn't look very intrusive if we want to go the “never do any kind of malloc(0) route (even if Hunk_Alloc() behavior is assumed to be predictible since that's our own custom code).

The only occurrences of ASSERT( size ) I got in new were outside of the game (like in the OpenGL driver).

@slipher
Copy link
Contributor

slipher commented May 3, 2024

In general, allocating an array of size zero is perfectly legitimate. This can commonly occur when allocating an array with a size determined at runtime. There is no reason to avoid zero-sized allocations with, e.g., the non-malloc-based "hunk" allocator.

The case of malloc in particular is a little awkward (for programs that bother to check for allocation failures) due to poor API design: allocating size 0 may "successfully" return null, which is indistinguishable from the failure case. Some programs might want to avoid requesting 0 bytes because of that. But you can just as well do it like

void* checked_malloc(size_t n) {
   void* p = malloc(n);
   if (!p && n) {
      puts("out of memory");
      _exit(1);
   }
   return p;
}

which seems good as you only have to check the size in the rarely taken "failure" branch.

But we are writing in C++, so we shouldn't be using malloc at all, only operator new. So the heuristic that malloc(0) is wrong, which is probably bogus in the first place, is less than useless as it will only complain about our dependencies. I have no idea why they would think it is "probably a bug".

@illwieckz
Copy link
Member Author

illwieckz commented May 3, 2024

I have no idea why they would think it is "probably a bug".

If you allocate a struct and then access the struct members, if the size is 0, you definitely not allocated the struct and the member access will crash.

I guess it's just that, why would someone call malloc() and never access what is returned? We can simply just not call malloc() and skip the whole rest of the code if there is nothing to do.

Even doing zero-size allocation with the ”non-malloc-based "hunk" allocator” looks weird because even if it is assumed it cannot fail (in the meaning the memory is already allocated and if there is a failure it will call Sys::Drop and not let the program run more), as far as I know calling it with a zero size returns the current position of the allocatable memory. It means it just returns the address of either uninitialized memory, or the memory of the next object to be allocated.

If there is nothing to allocate, I would just skip the allocation and the whole related processing code as well.

The only three occurrences I spotted of zero-size hunk allocations are in the BSP code (two of them being just hacks to get the current position of the allocatable memory), in the shader code, and in the MD3 model code. BSP, shader and MD3 being basically some of the oldest thing from the engine. There may be other uncaught places, but after I rewrote this 3 use cases in a branch (by just skipping allocation and processing if zero size), I could load a game without facing a zero-sized hunk allocation anymoe.

@slipher
Copy link
Contributor

slipher commented May 3, 2024

If you allocate a struct and then access the struct members, if the size is 0, you definitely not allocated the struct and the member access will crash.

I don't see how this would be a common bug as sizeof can never return 0.

I guess it's just that, why would someone call malloc() and never access what is returned?

Because they're allocating an array of length 0.

s_worldData.dataSize seems to be unused so you can nuke that one.

@illwieckz
Copy link
Member Author

I don't see how this would be a common bug as sizeof can never return 0.

When allocating an empty array 0 * sizeof always return 0, and the address of the array is also the address of the first cell. This is basically the shader.stages use case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
T-Question for questions on how things works on on how to proceed on an issue
Projects
None yet
Development

No branches or pull requests

2 participants