Skip to content

Commit aca240e

Browse files
authored
Windows: Fix unbounded growth of stored scoped attributes (#259)
1 parent b7d18a2 commit aca240e

File tree

2 files changed

+202
-22
lines changed

2 files changed

+202
-22
lines changed

Runtime/Native/Windows/NativeClient.cs

Lines changed: 142 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ private string GetPluginDirectoryPath()
333333
const string pluginDir = "Plugins";
334334
return Path.Combine(Application.dataPath, pluginDir);
335335
}
336+
336337
/// <summary>
337338
/// Generate path to Crashpad handler binary
338339
/// </summary>
@@ -367,12 +368,24 @@ internal static void CleanScopedAttributes()
367368
{
368369
return;
369370
}
370-
var attributes = JsonUtility.FromJson<ScopedAttributesContainer>(attributesJson);
371-
foreach (var attributeKey in attributes.Keys)
371+
372+
try
373+
{
374+
var attributes = JsonUtility.FromJson<ScopedAttributesContainer>(attributesJson);
375+
foreach (var attributeKey in attributes.Keys)
376+
{
377+
PlayerPrefs.DeleteKey(string.Format(ScopedAttributesPattern, attributeKey));
378+
}
379+
}
380+
catch (Exception e)
381+
{
382+
Debug.LogWarning($"Backtrace Failed to parse scoped attributes for cleanup: {e.Message}");
383+
}
384+
finally
372385
{
373-
PlayerPrefs.DeleteKey(string.Format(ScopedAttributesPattern, attributeKey));
386+
PlayerPrefs.DeleteKey(ScopedAttributeListKey);
387+
PlayerPrefs.Save();
374388
}
375-
PlayerPrefs.DeleteKey(ScopedAttributeListKey);
376389
}
377390

378391
internal static IDictionary<string, string> GetScopedAttributes()
@@ -382,8 +395,25 @@ internal static IDictionary<string, string> GetScopedAttributes()
382395
{
383396
return new Dictionary<string, string>();
384397
}
385-
var result = new Dictionary<string, string>();
386-
var attributes = JsonUtility.FromJson<ScopedAttributesContainer>(attributesJson);
398+
399+
var result = new Dictionary<string, string>(StringComparer.Ordinal);
400+
ScopedAttributesContainer attributes = null;
401+
402+
try
403+
{
404+
attributes = JsonUtility.FromJson<ScopedAttributesContainer>(attributesJson);
405+
}
406+
catch (Exception e)
407+
{
408+
Debug.LogWarning($"Backtrace Failed to parse scoped attributes at read: {e.Message}");
409+
return result;
410+
}
411+
412+
if (attributes?.Keys == null)
413+
{
414+
return result;
415+
}
416+
387417
foreach (var attributeKey in attributes.Keys)
388418
{
389419
var value = PlayerPrefs.GetString(string.Format(ScopedAttributesPattern, attributeKey), string.Empty);
@@ -411,7 +441,7 @@ internal static IDictionary<string, string> GetScopedAttributes()
411441
}
412442

413443
/// <summary>
414-
/// Adds attributes to scoped registry and to native clietn
444+
/// Adds attributes to scoped registry and to native client
415445
/// </summary>
416446
/// <param name="key">attribute key</param>
417447
/// <param name="value">attribute value</param>
@@ -424,23 +454,100 @@ private void AddAttributes(string key, string value)
424454
AddScopedAttribute(key, value);
425455
}
426456

457+
/// <summary>Load a deduped set of scoped attribute keys from PlayerPrefs.</summary>
458+
private HashSet<string> LoadScopedKeys()
459+
{
460+
var keys = new HashSet<string>(StringComparer.Ordinal);
461+
var attributesJson = PlayerPrefs.GetString(ScopedAttributeListKey);
462+
if (HasScopedAttributesEmpty(attributesJson))
463+
{
464+
try
465+
{
466+
var container = JsonUtility.FromJson<ScopedAttributesContainer>(attributesJson);
467+
foreach (var k in container.Keys)
468+
{
469+
if (!string.IsNullOrEmpty(k))
470+
{
471+
keys.Add(k);
472+
}
473+
}
474+
}
475+
catch (Exception e)
476+
{
477+
Debug.LogWarning($"Backtrace Failed to parse scoped attributes list. {e.Message}");
478+
keys.Clear();
479+
}
480+
}
481+
return keys;
482+
}
483+
484+
/// <summary>Persist a deduped list of keys back to PlayerPrefs.</summary>
485+
private void SaveScopedKeys(HashSet<string> keys)
486+
{
487+
var container = new ScopedAttributesContainer
488+
{
489+
Keys = keys.ToList()
490+
};
491+
PlayerPrefs.SetString(ScopedAttributeListKey, JsonUtility.ToJson(container));
492+
PlayerPrefs.Save();
493+
}
494+
495+
/// <summary>Return the currently stored "value" for a scoped key or "null" if missing.</summary>
496+
private string GetScopedValue(string key)
497+
{
498+
var prefsKey = string.Format(ScopedAttributesPattern, key);
499+
return PlayerPrefs.HasKey(prefsKey) ? PlayerPrefs.GetString(prefsKey) : null;
500+
}
501+
502+
/// <summary>Set the stored value for a scoped key.</summary>
503+
private void SetScopedValue(string key, string value)
504+
{
505+
PlayerPrefs.SetString(string.Format(ScopedAttributesPattern, key), value ?? string.Empty);
506+
}
507+
427508
/// <summary>
428509
/// Adds dictionary of attributes to player prefs for windows crashes captured by unity crash handler
429510
/// </summary>
430-
/// <param name="atributes">Attributes</param>
511+
/// <param name="attributes">Attributes</param>
431512
internal void AddScopedAttributes(IDictionary<string, string> attributes)
432513
{
433514
if (!_configuration.SendUnhandledGameCrashesOnGameStartup)
434515
{
435516
return;
436517
}
437-
var attributesContainer = new ScopedAttributesContainer();
438-
foreach (var attribute in attributes)
518+
if (attributes == null || attributes.Count == 0)
439519
{
440-
attributesContainer.Keys.Add(attribute.Key);
441-
PlayerPrefs.SetString(string.Format(ScopedAttributesPattern, attribute.Key), attribute.Value);
520+
return;
521+
}
522+
523+
var keys = LoadScopedKeys();
524+
bool listChanged = false;
525+
526+
foreach (var kv in attributes)
527+
{
528+
var key = kv.Key;
529+
var value = kv.Value ?? string.Empty;
530+
531+
// skip when unchanged
532+
var current = GetScopedValue(key);
533+
if (current == null || !string.Equals(current, value, StringComparison.Ordinal))
534+
{
535+
SetScopedValue(key, value);
536+
}
537+
538+
// deduplication
539+
if (keys.Add(key))
540+
{
541+
listChanged = true;
542+
}
442543
}
443-
PlayerPrefs.SetString(ScopedAttributeListKey, JsonUtility.ToJson(attributesContainer));
544+
545+
if (listChanged)
546+
{
547+
SaveScopedKeys(keys);
548+
}
549+
550+
PlayerPrefs.Save();
444551
}
445552

446553
/// <summary>
@@ -454,14 +561,29 @@ private void AddScopedAttribute(string key, string value)
454561
{
455562
return;
456563
}
457-
var attributesJson = PlayerPrefs.GetString(ScopedAttributeListKey);
458-
var attributes = HasScopedAttributesEmpty(attributesJson)
459-
? JsonUtility.FromJson<ScopedAttributesContainer>(attributesJson)
460-
: new ScopedAttributesContainer();
564+
if (string.IsNullOrEmpty(key))
565+
{
566+
return;
567+
}
568+
569+
var normalized = value ?? string.Empty;
570+
571+
// skip when unchanged
572+
var current = GetScopedValue(key);
573+
if (current != null && string.Equals(current, normalized, StringComparison.Ordinal))
574+
{
575+
return;
576+
}
577+
578+
SetScopedValue(key, normalized);
579+
580+
var keys = LoadScopedKeys();
581+
if (keys.Add(key))
582+
{
583+
SaveScopedKeys(keys);
584+
}
461585

462-
attributes.Keys.Add(key);
463-
PlayerPrefs.SetString(ScopedAttributeListKey, JsonUtility.ToJson(attributes));
464-
PlayerPrefs.SetString(string.Format(ScopedAttributesPattern, key), value);
586+
PlayerPrefs.Save();
465587
}
466588

467589
private static bool HasScopedAttributesEmpty(string attributesJson)

Tests/Runtime/Native/Windows/ScopedNativeAttributesTests.cs

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,27 @@ namespace Backtrace.Unity.Tests.Runtime.Native.Windows
1010
{
1111
public sealed class ScopedNativeAttributesTests
1212
{
13+
// PlayerPrefs key used by the Windows NativeClient to store the scoped list
14+
private const string ScopedKeyList = "backtrace-scoped-attributes";
15+
private const string ScopedValuePattern = "bt-{0}";
16+
1317
[TearDown]
1418
public void Setup()
1519
{
1620
CleanLegacyAttributes();
1721
NativeClient.CleanScopedAttributes();
22+
PlayerPrefs.DeleteAll();
1823
}
1924

25+
[Test]
2026
public void FreshStartup_ShouldntIncludeAnyAttributeFromPlayerPrefs_AttributesAreEmpty()
2127
{
2228
var attributes = NativeClient.GetScopedAttributes();
2329

2430
Assert.IsEmpty(attributes);
2531
}
2632

33+
[Test]
2734
public void LegacyAttributesSupport_ShouldIncludeLegacyAttributesWhenScopedAttributesAreNotAvailable_AllLegacyAttributesArePresent()
2835
{
2936
string testVersion = "0.1.0";
@@ -82,7 +89,6 @@ public void NativeCrashUploadAttributesSetting_ShouldReadPlayerPrefsWithLegacyAt
8289
[Test]
8390
public void NativeCrashUploadAttributes_ShouldSetScopedAttributeViaNativeClientApi_AttributePresentsInScopedAttributes()
8491
{
85-
8692
var configuration = ScriptableObject.CreateInstance<BacktraceConfiguration>();
8793
configuration.SendUnhandledGameCrashesOnGameStartup = true;
8894
const string testAttributeKey = "foo-key-bar-baz";
@@ -93,7 +99,6 @@ public void NativeCrashUploadAttributes_ShouldSetScopedAttributeViaNativeClientA
9399
var scopedAttributes = NativeClient.GetScopedAttributes();
94100

95101
Assert.AreEqual(scopedAttributes[testAttributeKey], testAttributeValue);
96-
97102
}
98103

99104
[Test]
@@ -119,6 +124,59 @@ public void NativeCrashAttributesCleanMethod_ShouldCleanAllScopedAttribtues_Scop
119124
Assert.IsNotEmpty(attributesBeforeCleanup);
120125
}
121126

127+
[Test]
128+
public void ScopedAttributes_ShouldNotDuplicateKeys_OnRepeatedAdds()
129+
{
130+
var configuration = ScriptableObject.CreateInstance<BacktraceConfiguration>();
131+
configuration.SendUnhandledGameCrashesOnGameStartup = true;
132+
133+
const string k = "dup-key";
134+
const string v = "v1";
135+
136+
var client = new NativeClient(configuration, null, new Dictionary<string, string>(), new List<string>());
137+
138+
client.SetAttribute(k, v);
139+
client.SetAttribute(k, v);
140+
client.SetAttribute(k, v);
141+
142+
var scoped = NativeClient.GetScopedAttributes();
143+
Assert.IsTrue(scoped.ContainsKey(k));
144+
Assert.AreEqual(v, scoped[k]);
145+
146+
var json = PlayerPrefs.GetString(ScopedKeyList);
147+
var occurrences = json.Split('"');
148+
int count = 0;
149+
foreach (var s in occurrences)
150+
{
151+
if (s == k) count++;
152+
}
153+
154+
Assert.AreEqual(1, count, "Key should be stored once in the scoped key list.");
155+
}
156+
157+
[Test]
158+
public void ScopedAttributes_ShouldSkipWrites_WhenValueUnchanged()
159+
{
160+
var configuration = ScriptableObject.CreateInstance<BacktraceConfiguration>();
161+
configuration.SendUnhandledGameCrashesOnGameStartup = true;
162+
163+
const string k = "stable-key";
164+
const string v = "same-value";
165+
166+
var client = new NativeClient(configuration, null, new Dictionary<string, string>(), new List<string>());
167+
168+
client.SetAttribute(k, v);
169+
var json1 = PlayerPrefs.GetString(ScopedKeyList);
170+
var val1 = PlayerPrefs.GetString(string.Format(ScopedValuePattern, k));
171+
172+
client.SetAttribute(k, v);
173+
var json2 = PlayerPrefs.GetString(ScopedKeyList);
174+
var val2 = PlayerPrefs.GetString(string.Format(ScopedValuePattern, k));
175+
176+
Assert.AreEqual(json1, json2, "Scoped key list JSON should not change when value is unchanged.");
177+
Assert.AreEqual(val1, val2, "Stored value should remain the same.");
178+
}
179+
122180
private void CleanLegacyAttributes()
123181
{
124182
PlayerPrefs.DeleteKey(NativeClient.VersionKey);

0 commit comments

Comments
 (0)