Skip to content

Conversation

@Dimi1010
Copy link
Collaborator

@Dimi1010 Dimi1010 commented Oct 24, 2025

Summary

This PR intended to improve the encapsulation and clarity of Layer field data. As per the C++ core guidelines protected data fields are generally undesired as they represent difficulty in maintaining base class invariants across derived classes.

The main object of this PR is the introduction of a new helper struct LayerAllocationInfo in the internal namespace, to hold allocation information for Layer objects and provide behavior helper methods. The helper structure replaces is contained as a field in a Layer to represent its current allocation state.

LayerAllocationInfo and chosen fields.

The information structure contains 2 members:

  • Packet* attachedPacket, replacing m_Packet pointer inside Layer.
  • bool managedByPacket, replacing m_isAllocatedInPacket flag inside Layer.

The replacements were chosen due to their relevancy to the allocation behaviour of Layer and little else. Those fields were previously intermixed with other fields in the Layer object causing confusion when determining their use and scope.

  • attachedPacket (previously m_Packet) is exclusively used for indication if the layer's data is owned by a packet and to forward requests for structural data changes to the underlying packet buffer, if available. The new name was chosen to better represent that use.
  • managedByPacket (previously m_IsAllocatedInPacket) is exclusively used as flag to determine if the Layer object's lifecycle is managed by the Packet instance it is attached to. The field intricately tied to the state of attachedPacket and as such is included to the helper structure.

The new fields include extensive documentation regarding their use, improving the maintainability of the code. ( Think about the you in 5 months reading it 🙂 )

Behaviour methods instead of direct field mutation

The new helper structure adds two behavior methods:

  • attachPacket(Packet* packet, bool managed, bool force = false): This method encapsulates the behavior of attaching a layer to a Packet instance. It allows a Packet instance attaching a Layer to itself to update the information in one step instead of manually having to directly update private fields to the Layer instance. The method also provides integrated sanity checks if a Layer is being attached to a Packet instance twice.
  • detach(): Encapsulates the behaviour of detaching a Layer from Packet. It allows a Packet instance detaching a Layer to inform it of its new state in one step.

Protected data accessors and private fields

The new LayerAllocationInfo structure is held as a private field inside Layer. The change improves data integrity as the allocation information (packet or managed flag) is unavailable for accidental modification by derived classes.

To keep existing behaviour of a Layer instance forwarding its attachment to any other instance it constructs during parsing, a new protected accessor getAttachedPacket() has been added and instances of direct use of m_Packet are replaced by it.

Bug Fixes

  • Fixed bug due to lack of zeroing of m_IsAllocatedInPacket flag when detaching a layer. If said layer is attached to a new packet instance, the old implementation disregarded the ownership flag and considered the layer instance as owned by the packet, due to the layer flag being set from the previous packet.

Comment on lines +29 to 31
// Should this really always delete m_Data? What if the layer is attached to a packet?
if (m_Data != nullptr)
delete[] m_Data;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment? This would cause an error if called on a Layer that does not own its data span.

else
{
layer->m_Packet = nullptr;
layer->m_AllocationInfo.detach();
Copy link
Collaborator Author

@Dimi1010 Dimi1010 Oct 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line in particular created a subtle bug in the old implementation.

If a layer is originally owned by the packet instance, and detached, the m_IsAllocatedInPacket flag was not zeroed. If the layer has then been attached to another Packet. The attached layer was considered as if managed by the new packet even if attached with addLayer(layer, false) which should not transfer ownership of the layer object.

@Dimi1010 Dimi1010 added the bug label Oct 24, 2025
///
/// If 'false', the Layer object is considered unmanaged and the user is responsible for freeing it.
/// This is commonly the case for layers created on the stack and attached to a Packet.
bool managedByPacket = false;
Copy link
Collaborator Author

@Dimi1010 Dimi1010 Oct 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe ownedByPacket is a more descriptive name? 🤔

Comment on lines +330 to +332
PTF_ASSERT_TRUE(packetWithoutTunnel.addLayer(vxlanEthLayer, true));
PTF_ASSERT_TRUE(packetWithoutTunnel.addLayer(vxlanIP4Layer, true));
PTF_ASSERT_TRUE(packetWithoutTunnel.addLayer(vxlanIcmpLayer, true));
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or should we keep the test as is, but correctly delete the layers afterwards?

@codecov
Copy link

codecov bot commented Oct 24, 2025

Codecov Report

❌ Patch coverage is 63.48684% with 111 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.45%. Comparing base (8b1a9a5) to head (1c73cfb).

Files with missing lines Patch % Lines
Packet++/src/Sll2Layer.cpp 6.25% 13 Missing and 2 partials ⚠️
Packet++/src/IPv6Layer.cpp 40.90% 12 Missing and 1 partial ⚠️
Packet++/src/SllLayer.cpp 18.75% 12 Missing and 1 partial ⚠️
Packet++/src/GreLayer.cpp 29.41% 10 Missing and 2 partials ⚠️
Packet++/src/IPSecLayer.cpp 27.27% 7 Missing and 1 partial ⚠️
Packet++/src/NullLoopbackLayer.cpp 42.85% 7 Missing and 1 partial ⚠️
Packet++/src/VlanLayer.cpp 52.94% 7 Missing and 1 partial ⚠️
Packet++/src/IPv4Layer.cpp 72.00% 7 Missing ⚠️
Packet++/src/TcpLayer.cpp 76.00% 6 Missing ⚠️
Packet++/src/NflogLayer.cpp 20.00% 3 Missing and 1 partial ⚠️
... and 10 more
Additional details and impacted files
@@            Coverage Diff             @@
##              dev    #2004      +/-   ##
==========================================
- Coverage   83.46%   83.45%   -0.01%     
==========================================
  Files         311      311              
  Lines       54568    54593      +25     
  Branches    11808    11851      +43     
==========================================
+ Hits        45545    45561      +16     
+ Misses       8313     8208     -105     
- Partials      710      824     +114     
Flag Coverage Δ
alpine320 75.89% <57.99%> (-0.01%) ⬇️
fedora42 75.45% <58.36%> (-0.02%) ⬇️
macos-14 81.57% <60.13%> (-0.01%) ⬇️
macos-15 81.56% <60.13%> (-0.03%) ⬇️
mingw32 69.94% <47.52%> (-0.07%) ⬇️
mingw64 69.92% <47.52%> (+0.05%) ⬆️
npcap ?
rhel94 75.48% <58.20%> (+<0.01%) ⬆️
ubuntu2004 59.46% <57.50%> (-0.01%) ⬇️
ubuntu2004-zstd 59.56% <57.50%> (-0.01%) ⬇️
ubuntu2204 75.41% <58.20%> (-0.01%) ⬇️
ubuntu2204-icpx 57.86% <34.47%> (+0.01%) ⬆️
ubuntu2404 75.51% <57.83%> (+0.01%) ⬆️
ubuntu2404-arm64 75.57% <57.83%> (+0.02%) ⬆️
unittest 83.45% <63.48%> (-0.01%) ⬇️
windows-2022 85.42% <74.11%> (+0.16%) ⬆️
windows-2025 85.44% <74.11%> (+0.11%) ⬆️
winpcap 85.44% <74.11%> (-0.09%) ⬇️
xdp 53.00% <55.97%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Dimi1010 Dimi1010 marked this pull request as ready for review October 24, 2025 22:53
@Dimi1010 Dimi1010 requested a review from seladb as a code owner October 24, 2025 22:54
@seladb
Copy link
Owner

seladb commented Nov 7, 2025

@Dimi1010 I'm not sure why replacing 2 protected members (m_Packet, m_IsAllocatedInPacket) with 1 private struct (m_AllocationInfo) that contains both of them improves anything... if we want to avoid using protected methods (which I'm not sure why it is better), we can just introduce getAttachedPacket(), but TBH I'm not sure this change is needed also

@Dimi1010
Copy link
Collaborator Author

Dimi1010 commented Nov 7, 2025

@Dimi1010 I'm not sure why replacing 2 protected members (m_Packet, m_IsAllocatedInPacket) with 1 private struct (m_AllocationInfo) that contains both of them improves anything... [...]

@seladb It mitigates the risk of this issue #2004 (comment) which was a bug that can be hard to track and easy to happen and completely broke the memory ownership model.

Having them in the same struct indicates that the two variables are much more tightly related compared to let's say, m_Packet and m_Data. It makes it a bit harder to overwrite only one of the variables, forgetting to update the other.

If the allocation info needs to be updated, it is more likely to be done through behaviour actions (m_AllocInfo.attachPacket() / detach()) that conceptualize the entire operation or completely overwriting the allocation info object.

IMO, this is easier to understand and less bug-prone at the point of use, than having to remember to flip N variables manually every time the operation needs to be performed.

[...] if we want to avoid using protected methods (which I'm not sure why it is better), we can just introduce getAttachedPacket(), but TBH I'm not sure this change is needed also

Protected data make it a pain to ensure invariants in the base classes of an inheritance hierarchy. That is especially true for large hierarchies like Layer [1]. Let's take the allocation info for example. It is solely managed by the Layer base class (+ Packet). Derived classes shouldn't modify it, because they shouldn't deal with allocation logic. They should just add parsing logic of to the data buffer.

If it was protected data, I can at ANY POINT in ANY derived class, write m_AllocationInfo = AllocationInfo{} or [...] = AllocationInfo{ someOtherPacketPtr, true }. Congratulations, the invariant is broken. Everything that relied on m_AllocationInfo being synchronized with other logic (e.g the actual packet that holds the layer) can go and burn. It is the reason why it was added in the C++ core guidelines in the segment "avoid protected data".

Making it private with RO accessors (if RO access is needed), changes the situation from "It can happen, but shouldn't." to "It can't happen". Removing the need to scan every code change for if a bug of such nature was introduced.

PS:
[1] - IMO the Layer hierarchy is somewhat too large already, as separate payload messages should not be represented via inheritance as they are not truly a new network protocol type. I'm looking at HTTPRequest and HTTPRespose, etc. IMO, it should be HTTPLayer with HTTPRequestPayload[View] / HTTPResposePayload[View], you can bind to the HTTPLayer to mutate the data buffer.

@seladb
Copy link
Owner

seladb commented Nov 9, 2025

@seladb It mitigates the risk of this issue #2004 (comment) which was a bug that can be hard to track and easy to happen and completely broke the memory ownership model.

There are very few places that need to update these members, and most (if not all) of them reside in the Packet class. So if there's a bug it should not be very hard to find and fix it

Making it private with RO accessors (if RO access is needed), changes the situation from "It can happen, but shouldn't." to "It can't happen". Removing the need to scan every code change for if a bug of such nature was introduced.

I agree that layers shouldn't deal with the allocation logic, and I'm pretty sure none of them do. In practice, they never need to unless there's a very special use case. I guess we can leave the RO method although I don't think it's a real pain for people writing protocol parsers - I don't recall a case where I asked someone not to change these members...

@Dimi1010
Copy link
Collaborator Author

Dimi1010 commented Nov 11, 2025

There are very few places that need to update these members, and most (if not all) of them reside in the Packet class. So if there's a bug it should not be very hard to find and fix it

It wouldn't have been that simple to find. I found it only after I grouped the variables and a test suddenly started memory leaking due to being incorrect. It was attaching the layer without ownership and then not deleting the layer. Everything still appeared to run fine because the packet deleted it erroneously.

Also, IMO that is only treating the symptom. In memory ownership schemes, generally keeping things simple at the point of use is key to prevent errors. Having to manually remember which variables to flip is not simple at the point of use, even if its only a few places. Having the complex actions encapsulated in methods is better due to explicit requirement for all parameters to be provided before executing the action.

I agree that layers shouldn't deal with the allocation logic, and I'm pretty sure none of them do. In practice, they never need to unless there's a very special use case. I guess we can leave the RO method although I don't think it's a real pain for people writing protocol parsers - I don't recall a case where I asked someone not to change these members...

In practice, if the fields aren't needed by the derived classes then they shouldn't be exposed to them. I don't think we will have any special use cases where a derived class will need to change its owned packet from its internals. The only need they have for the packet is to forward the pointer to the next layer which is RO operation.

@seladb
Copy link
Owner

seladb commented Nov 16, 2025

It wouldn't have been that simple to find. I found it only after I grouped the variables and a test suddenly started memory leaking due to being incorrect. It was attaching the layer without ownership and then not deleting the layer. Everything still appeared to run fine because the packet deleted it erroneously.

You're saying the bug wasn't exposed until you refactored the code and wrote a specific test? It's a good sign that it's an edge case that would rarely happen. We have many protocols using this infrastructure, and they all work fine.

That's another indication that this refactoring is not needed - "if it ain't broken don't fix it" 😄

In practice, if the fields aren't needed by the derived classes then they shouldn't be exposed to them. I don't think we will have any special use cases where a derived class will need to change its owned packet from its internals. The only need they have for the packet is to forward the pointer to the next layer which is RO operation.

I don't disagree with you, but I don't think it's "painful" enough that we need to modify this code

@Dimi1010
Copy link
Collaborator Author

Dimi1010 commented Nov 16, 2025

You're saying the bug wasn't exposed until you refactored the code and wrote a specific test? It's a good sign that it's an edge case that would rarely happen. We have many protocols using this infrastructure, and they all work fine.

That's another indication that this refactoring is not needed - "if it ain't broken don't fix it" 😄

@seladb I am saying that the old test was written wrong and did not catch the bug in memory semantics. The bug still existed. The thing that happened was that the refactoring fixed the bug in the Layer's attach/detach procedure and the test started failing because it had another bug of not cleaning up the layers it detached.

It was a classic case of 2 bugs covering each other in that particular situation.

This is the test case: https://github.com/seladb/PcapPlusPlus/pull/2004/files#diff-995c851e4dd7dc72ed1ba9d8bf87403a50d00e69f92d31cfcab69577b42763a1L310-R332

// The layers are detached from the packet here.
pcpp::EthLayer* vxlanEthLayer = (pcpp::EthLayer*)vxlanPacketOrig.detachLayer(pcpp::Ethernet, 1);
pcpp::IcmpLayer* vxlanIcmpLayer = (pcpp::IcmpLayer*)vxlanPacketOrig.detachLayer(pcpp::ICMP);
pcpp::IPv4Layer* vxlanIP4Layer = (pcpp::IPv4Layer*)vxlanPacketOrig.detachLayer(pcpp::IPv4, 1);

/* Assertion code irrelevant to the issue */
/* [...] */

// This is the test old code. The commented false is because that is the default value for take ownership.
// See how we instruct the attach operation to NOT take ownership of the layer when attaching it again.
PTF_ASSERT_TRUE(packetWithoutTunnel.addLayer(vxlanEthLayer, /* false */));
PTF_ASSERT_TRUE(packetWithoutTunnel.addLayer(vxlanIP4Layer, /* false */));
PTF_ASSERT_TRUE(packetWithoutTunnel.addLayer(vxlanIcmpLayer, /* false */));

This should cause the the test to memory leak, because the layers aren't cleaned up explicitly in the test afterwards.
This is a error in the test code.

Instead what happened was that the test runs fine without memory leaks, because the layer's ownership gets erroneously transferred anyway, because the flag m_IsAllocatedInPacket:

  • on detach the flag wasn't properly reset, keeping its old value of true.
  • on attach the flag is only updated if the second param is true. On false it is untouched, so it keeps its old value.

This leads to the flag keeping its old value of true after being attached. The packet instance thus considers that its responsible for it and cleans it up, even though it shouldn't.

In a correctly written test case that properly handled the cleanup of the layers, it would have lead to double free bug.

This attach / detach bug is very easily reproduced by having:

  1. Make a layer
  2. Attach the layer to a packet and have the packet take ownership of the layer
  3. Detach the layer from the packet
  4. Attack the layer to a packet and have the packet NOT take ownership.
  5. Delete the packet
    5.1 See how the packet still deletes the layer.
  6. Attempt to use the layer and explode because that is now a dangling pointer, because Packet deleted the layer object.

Here is the sample reproducing the memory ownership bug. Run this on the current dev or master.
It only needs a change the visibility of m_IsAllocatedInPacket to public to compile as it can't be accessed otherwise.

int main(int argc, char* argv[])
{
	using namespace pcpp;

	Packet* pkt = new Packet();
	Layer* layer = new ArpLayer(ArpReply(MacAddress(), IPv4Address(), MacAddress(), IPv4Address()));
	
	// Attach the layer to the packet, ownership should transfer to the packet
	pkt->addLayer(layer, true);
	std::cout << "This should be true:" << layer->m_IsAllocatedInPacket << std::endl;

	// Detach the layer from the packet, ownership should transfer to the user
	pkt->detachLayer(layer);
	std::cout << "This should be false:" << layer->m_IsAllocatedInPacket << std::endl;

	// Attach the layer to the packet, ownership should NOT transfer to the packet
	pkt->addLayer(layer, false);
	std::cout << "This should be false:" << layer->m_IsAllocatedInPacket << std::endl;

	// Delete packet. In the erogenous case this will also delete the layer object.
	delete pkt;

	// In the correct case, this should work, as the layer should still be alive.
	// In the erogenous case. This will dereference a dangling pointer and crash.
	layer->m_IsAllocatedInPacket;

	// This should be done by the user since ownership was never transferred back to the packet
	// In the erogenous case, it will cause a double free and crash.
	delete layer;

	return 0;
}

This entire issue is why I prefer my ownership mechanics to be somewhat encapsulated into simple objects / operations then relying on flipping flags manually.

It might be a somewhat uncommon case of detaching and reattaching a layer, but IMO it is unacceptable behaviour as it breaks the memory ownership model which is of critical importance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants