Skip to content

Commit 6afbdc6

Browse files
fix: DontDestroyWithOwner v2.4.0 - v2.4.2 client-server regression (#3522)
This resolves the [Forum-1658887](https://discussions.unity.com/t/network-objects-dont-despawn-on-disconnect-even-with-dont-destroy-with-owner-unchecked/1658887) discovered client-server specific issue in the v2.4.2 release where spawned objects with the default `NetworkObject.DontDestroyWithOwner` setting (disabled/false) would not be despawned and destroyed when the owning client disconnected. ## Changelog - Fixed: Issue where spawned objects with `NetworkObject.DontDestroyWithOwner` set to `false` would not be destroyed when using a client-server network topology. ## Testing and Documentation - Includes updates to the `NetworkObjectDontDestroyWithOwnerTests` integration test. - No documentation changes or additions were necessary. <!-- Uncomment and mark items off with a * if this PR deprecates any API: ### Deprecated API - [ ] An `[Obsolete]` attribute was added along with a `(RemovedAfter yyyy-mm-dd)` entry. - [ ] An [api updater] was added. - [ ] Deprecation of the API is explained in the CHANGELOG. - [ ] The users can understand why this API was removed and what they should use instead. --> ## Backport This is specific to NGO v2.4.2 release. <!-- If this is a backport: - Add the following to the PR title: "\[Backport\] ..." . - Link to the original PR. If this needs a backport - state this here If a backport is not needed please provide the reason why. If the "Backports" section is not present it will lead to a CI test failure. -->
1 parent 7bbc1c3 commit 6afbdc6

File tree

3 files changed

+174
-42
lines changed

3 files changed

+174
-42
lines changed

com.unity.netcode.gameobjects/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
66

77
Additional documentation and release notes are available at [Multiplayer Documentation](https://docs-multiplayer.unity3d.com).
88

9+
## [Unreleased]
10+
11+
### Fixed
12+
13+
- Fixed issue where spawned objects with `NetworkObject.DontDestroyWithOwner` is set to `false` would not be destroyed when using a client-server network topology. (#3522)
14+
15+
916
## [2.4.2] - 2025-06-13
1017

1118
### Fixed

com.unity.netcode.gameobjects/Runtime/Connection/NetworkConnectionManager.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1178,7 +1178,7 @@ internal void OnClientDisconnectFromServer(ulong clientId)
11781178
{
11791179
// If destroying with owner, then always despawn and destroy (or defer destroying to prefab handler)
11801180
// Handle an object with no observers other than the current disconnecting client as destroying with owner
1181-
if (!ownedObject.DontDestroyWithOwner && (ownedObject.Observers.Count == 0 || (ownedObject.Observers.Count == 1 && ownedObject.Observers.Contains(clientId))))
1181+
if (!ownedObject.DontDestroyWithOwner && (ownedObject.Observers.Count == 0 || (ownedObject.Observers.Count >= 1 && ownedObject.Observers.Contains(clientId))))
11821182
{
11831183
if (NetworkManager.PrefabHandler.ContainsHandler(ownedObject.GlobalObjectIdHash))
11841184
{

com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs

Lines changed: 166 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using System.Collections;
2+
using System.Collections.Generic;
23
using System.Linq;
4+
using System.Text;
35
using NUnit.Framework;
46
using Unity.Netcode.TestHelpers.Runtime;
57
using UnityEngine;
@@ -13,72 +15,162 @@ namespace Unity.Netcode.RuntimeTests
1315
[TestFixture(HostOrServer.Server)]
1416
internal class NetworkObjectDontDestroyWithOwnerTests : NetcodeIntegrationTest
1517
{
16-
private const int k_NumberObjectsToSpawn = 32;
17-
protected override int NumberOfClients => 1;
18+
private const int k_NumberObjectsToSpawn = 16;
19+
protected override int NumberOfClients => 3;
1820

19-
// TODO: [CmbServiceTests] Adapt to run with the service
20-
protected override bool UseCMBService()
21-
{
22-
return false;
23-
}
24-
25-
protected GameObject m_PrefabToSpawn;
21+
protected GameObject m_DestroyWithOwnerPrefab;
22+
protected GameObject m_DontDestroyWithOwnerPrefab;
2623
protected GameObject m_PrefabNoObserversSpawn;
2724

25+
private ulong m_NonAuthorityClientId;
26+
27+
private List<ulong> m_DontDestroyObjectIds = new List<ulong>();
28+
private List<ulong> m_DestroyObjectIds = new List<ulong>();
29+
2830
public NetworkObjectDontDestroyWithOwnerTests(HostOrServer hostOrServer) : base(hostOrServer) { }
2931

3032
protected override void OnServerAndClientsCreated()
3133
{
32-
m_PrefabToSpawn = CreateNetworkObjectPrefab("ClientOwnedObject");
33-
m_PrefabToSpawn.GetComponent<NetworkObject>().DontDestroyWithOwner = true;
34+
m_DontDestroyWithOwnerPrefab = CreateNetworkObjectPrefab("DestroyWith");
35+
m_DontDestroyWithOwnerPrefab.GetComponent<NetworkObject>().DontDestroyWithOwner = true;
36+
37+
m_DestroyWithOwnerPrefab = CreateNetworkObjectPrefab("DontDestroyWith");
3438

3539
m_PrefabNoObserversSpawn = CreateNetworkObjectPrefab("NoObserversObject");
3640
var prefabNoObserversNetworkObject = m_PrefabNoObserversSpawn.GetComponent<NetworkObject>();
3741
prefabNoObserversNetworkObject.SpawnWithObservers = false;
3842
prefabNoObserversNetworkObject.DontDestroyWithOwner = true;
3943
}
4044

41-
[UnityTest]
42-
public IEnumerator DontDestroyWithOwnerTest()
45+
/// <summary>
46+
/// Validates all instances of both prefab types have spawned on all clients.
47+
/// </summary>
48+
private bool HaveAllObjectInstancesSpawned(StringBuilder errorLog)
49+
{
50+
foreach (var networkManager in m_NetworkManagers)
51+
{
52+
var relativeSpawnedObjects = networkManager.SpawnManager.SpawnedObjects;
53+
for (int i = 0; i < k_NumberObjectsToSpawn; i++)
54+
{
55+
var dontDestroyObjectId = m_DontDestroyObjectIds[i];
56+
var destroyObjectId = m_DestroyObjectIds[i];
57+
if (!relativeSpawnedObjects.ContainsKey(dontDestroyObjectId))
58+
{
59+
errorLog.AppendLine($"[Client-{networkManager.LocalClientId}][DontDestroyWithOwner] Has not spawned {nameof(NetworkObject)}-{dontDestroyObjectId}!");
60+
}
61+
if (!relativeSpawnedObjects.ContainsKey(destroyObjectId))
62+
{
63+
errorLog.AppendLine($"[Client-{networkManager.LocalClientId}][DestroyWithOwner] Has not spawned {nameof(NetworkObject)}-{destroyObjectId}!");
64+
}
65+
}
66+
}
67+
return errorLog.Length == 0;
68+
}
69+
70+
/// <summary>
71+
/// Helper method to spawn the two different sets of objects.
72+
/// Those that will destroy with the owner and those that will not.
73+
/// </summary>
74+
/// <param name="dontDestroyWithOwner">type of prefab to use for spawning</param>
75+
private void SpawnAllObjects(bool dontDestroyWithOwner)
4376
{
44-
var client = m_ClientNetworkManagers[0];
45-
var clientId = client.LocalClientId;
46-
var networkObjects = SpawnObjects(m_PrefabToSpawn, m_ClientNetworkManagers[0], k_NumberObjectsToSpawn);
77+
var networkObjectIds = new List<ulong>();
78+
var nonAuthority = m_NetworkManagers.Where((c) => c.LocalClientId == m_NonAuthorityClientId).First();
79+
var objectToSpawn = dontDestroyWithOwner ? m_DontDestroyWithOwnerPrefab : m_DestroyWithOwnerPrefab;
80+
var spawnedObjects = SpawnObjects(objectToSpawn, nonAuthority, k_NumberObjectsToSpawn);
81+
foreach (var spawnedObject in spawnedObjects)
82+
{
83+
networkObjectIds.Add(spawnedObject.GetComponent<NetworkObject>().NetworkObjectId);
84+
}
4785

48-
// wait for object spawn on client to reach k_NumberObjectsToSpawn + 1 (k_NumberObjectsToSpawn and 1 for the player)
49-
yield return WaitForConditionOrTimeOut(() => client.SpawnManager.GetClientOwnedObjects(clientId).Count() == k_NumberObjectsToSpawn + 1);
50-
Assert.False(s_GlobalTimeoutHelper.TimedOut, $"Timed out waiting for client to have 33 NetworkObjects spawned! Only {client.SpawnManager.GetClientOwnedObjects(clientId).Count()} were assigned!");
86+
if (dontDestroyWithOwner)
87+
{
88+
m_DontDestroyObjectIds.Clear();
89+
m_DontDestroyObjectIds.AddRange(networkObjectIds);
90+
}
91+
else
92+
{
93+
m_DestroyObjectIds.Clear();
94+
m_DestroyObjectIds.AddRange(networkObjectIds);
95+
}
96+
}
5197

52-
// Since clients spawn their objects locally in distributed authority mode, we have to rebuild the list of the client
53-
// owned objects on the (DAHost) server-side because when the client disconnects it will destroy its local instances.
54-
if (m_DistributedAuthority)
98+
/// <summary>
99+
/// Validates that the non-authority owner client disconnection
100+
/// was registered on all clients.
101+
/// </summary>
102+
private bool NonAuthorityHasDisconnected(StringBuilder errorLog)
103+
{
104+
foreach (var networkManager in m_NetworkManagers)
55105
{
56-
networkObjects.Clear();
57-
var serversideClientOwnedObjects = m_ServerNetworkManager.SpawnManager.GetClientOwnedObjects(clientId);
106+
if (!networkManager.IsListening)
107+
{
108+
continue;
109+
}
110+
111+
if (networkManager.ConnectedClientsIds.Contains(m_NonAuthorityClientId))
112+
{
113+
errorLog.AppendLine($"[Client-{networkManager.LocalClientId}][ClientDisconnect] Still thinks Client-{m_NonAuthorityClientId} is connected!");
114+
}
115+
}
116+
return errorLog.Length == 0;
117+
}
58118

59-
foreach (var networkObject in serversideClientOwnedObjects)
119+
/// <summary>
120+
/// The primary validation for the <see cref="DontDestroyWithOwnerTest"/>.
121+
/// This validates that:
122+
/// - Spawned objects that are set to destroy with the owner gets destroyed/despawned when the owning client disconnects.
123+
/// - Spawned objects that are set to not destroy with the owner are not destroyed/despawned when the owning client disconnects.
124+
/// </summary>
125+
private bool ValidateDontDestroyWithOwner(StringBuilder errorLog)
126+
{
127+
foreach (var networkManager in m_NetworkManagers)
128+
{
129+
var relativeSpawnedObjects = networkManager.SpawnManager.SpawnedObjects;
130+
for (int i = 0; i < k_NumberObjectsToSpawn; i++)
60131
{
61-
if (!networkObject.IsPlayerObject)
132+
var dontDestroyObjectId = m_DontDestroyObjectIds[i];
133+
var destroyObjectId = m_DestroyObjectIds[i];
134+
if (!relativeSpawnedObjects.ContainsKey(dontDestroyObjectId))
135+
{
136+
errorLog.AppendLine($"[Client-{networkManager.LocalClientId}][DontDestroyWithOwner][!Despawned!] {nameof(NetworkObject)}-{dontDestroyObjectId} should not despawn upon the owner disconnecting!");
137+
}
138+
if (relativeSpawnedObjects.ContainsKey(destroyObjectId))
62139
{
63-
networkObjects.Add(networkObject.gameObject);
140+
errorLog.AppendLine($"[Client-{networkManager.LocalClientId}][DestroyWithOwner][!Not Despawned!] {nameof(NetworkObject)}-{destroyObjectId} should have despawned upon the owner disconnecting!");
64141
}
65142
}
66143
}
144+
return errorLog.Length == 0;
145+
}
67146

68-
// disconnect the client that owns all the clients
69-
NetcodeIntegrationTestHelpers.StopOneClient(client);
147+
/// <summary>
148+
/// This validates that:
149+
/// - Spawned objects that are set to destroy with the owner gets destroyed/despawned when the owning client disconnects.
150+
/// - Spawned objects that are set to not destroy with the owner are not destroyed/despawned when the owning client disconnects.
151+
/// </summary>
152+
[UnityTest]
153+
public IEnumerator DontDestroyWithOwnerTest()
154+
{
155+
var authority = GetAuthorityNetworkManager();
156+
var nonAuthority = GetNonAuthorityNetworkManager();
157+
m_NonAuthorityClientId = nonAuthority.LocalClientId;
158+
SpawnAllObjects(true);
159+
SpawnAllObjects(false);
70160

71-
var remainingClients = Mathf.Max(0, TotalClients - 1);
72-
// wait for disconnect
73-
yield return WaitForConditionOrTimeOut(() => m_ServerNetworkManager.ConnectedClients.Count == remainingClients);
74-
Assert.False(s_GlobalTimeoutHelper.TimedOut, "Timed out waiting for client to disconnect!");
161+
// This should never fail.
162+
Assert.IsTrue(m_DontDestroyObjectIds.Count == m_DestroyObjectIds.Count, $"Mismatch in spawn count! ({m_DontDestroyObjectIds.Count}) vs ({m_DestroyObjectIds.Count})");
75163

76-
for (int i = 0; i < networkObjects.Count; i++)
77-
{
78-
var networkObject = networkObjects[i].GetComponent<NetworkObject>();
79-
// ensure ownership was transferred back
80-
Assert.That(networkObject.OwnerClientId == m_ServerNetworkManager.LocalClientId);
81-
}
164+
yield return WaitForConditionOrTimeOut(HaveAllObjectInstancesSpawned);
165+
AssertOnTimeout($"Timed out waiting for all clients to spawn objects!");
166+
167+
yield return StopOneClient(nonAuthority);
168+
169+
yield return WaitForConditionOrTimeOut(NonAuthorityHasDisconnected);
170+
AssertOnTimeout($"Timed out waiting for all clients to register that Client-{m_NonAuthorityClientId} has disconnected!");
171+
172+
yield return WaitForConditionOrTimeOut(ValidateDontDestroyWithOwner);
173+
AssertOnTimeout($"Timed out while validating the DontDestroyWithOwnerTest results!");
82174
}
83175

84176
[UnityTest]
@@ -87,22 +179,55 @@ public IEnumerator NetworkShowThenClientDisconnects()
87179
var authorityManager = GetAuthorityNetworkManager();
88180
var networkObject = SpawnObject(m_PrefabNoObserversSpawn, authorityManager).GetComponent<NetworkObject>();
89181
var longWait = new WaitForSeconds(0.25f);
182+
// Wait long enough to assure that no client receives the spawn notification
90183
yield return longWait;
184+
185+
foreach (var networkManager in m_NetworkManagers)
186+
{
187+
// Skip the authority as it will have an instance
188+
if (networkManager == authorityManager)
189+
{
190+
continue;
191+
}
192+
Assert.False(networkManager.SpawnManager.SpawnedObjects.ContainsKey(networkObject.NetworkObjectId), $"[Client-{networkManager.LocalClientId}]" +
193+
$" Spawned an instance of {networkObject.name} when it was spawned with no observers!");
194+
}
195+
196+
// Get a non-authority client, show the spawned object to it, and then make that client the owner.
91197
var nonAuthorityManager = GetNonAuthorityNetworkManager();
92-
Assert.False(nonAuthorityManager.SpawnManager.SpawnedObjects.ContainsKey(networkObject.NetworkObjectId), $"[Client-{nonAuthorityManager.LocalClientId}] " +
93-
$"Already has an instance of {networkObject.name} when it should not!");
94198
networkObject.NetworkShow(nonAuthorityManager.LocalClientId);
199+
// This validates that the change in ownership is not sent when making a NetworkObject visible and changing the ownership
200+
// within the same frame/callstack.
95201
networkObject.ChangeOwnership(nonAuthorityManager.LocalClientId);
96202

203+
// Verifies the object was spawned on the non-authority client and that the non-authority client is the owner.
97204
yield return WaitForConditionOrTimeOut(() => nonAuthorityManager.SpawnManager.SpawnedObjects.ContainsKey(networkObject.NetworkObjectId)
98205
&& nonAuthorityManager.SpawnManager.SpawnedObjects[networkObject.NetworkObjectId].OwnerClientId == nonAuthorityManager.LocalClientId);
99206
AssertOnTimeout($"[Client-{nonAuthorityManager.LocalClientId}] Failed to spawn {networkObject.name} when it was shown!");
100207

208+
foreach (var networkManager in m_NetworkManagers)
209+
{
210+
// Skip the authority and the non-authority client as they should now both have instances
211+
if (networkManager == authorityManager || networkManager == nonAuthorityManager)
212+
{
213+
continue;
214+
}
215+
216+
// No other client should have an instance
217+
Assert.False(networkManager.SpawnManager.SpawnedObjects.ContainsKey(networkObject.NetworkObjectId), $"[Client-{networkManager.LocalClientId}]" +
218+
$" Spawned an instance of {networkObject.name} when it was shown to Client-{nonAuthorityManager.LocalClientId}!");
219+
}
220+
221+
// Wait a few frames
101222
yield return s_DefaultWaitForTick;
102223

224+
// Shutdown the non-authority client to assure this does not cause the spawned object to despawn
103225
nonAuthorityManager.Shutdown();
104226

227+
// Wait long enough to assure all messages generated from the client shutting down have been processed
105228
yield return longWait;
229+
230+
// Validate the object is still spawned
106231
Assert.True(networkObject.IsSpawned, $"The spawned test prefab was despawned on the authority side when it shouldn't have been!");
107232
}
108233
}

0 commit comments

Comments
 (0)