Skip to content

Commit 6880de6

Browse files
test(audience): Full→Anonymous userId race stress test
Mirrors SetConsent_DowngradeToNone_StressTest_NoLeak but exercises the Full → Anonymous path: an Identify-set userId must not leak through a racing Track past ApplyAnonymousDowngrade. Without the EnqueueChecked transform (commit c14cd391), this leaks reproducibly. With the fix, zero leaks across 200 × 4 iterations. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 189f4d6 commit 6880de6

1 file changed

Lines changed: 66 additions & 0 deletions

File tree

src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,72 @@ public void SetConsent_DowngradeToNone_StressTest_NoLeak()
601601
}
602602
}
603603

604+
[Test]
605+
public void SetConsent_DowngradeToAnonymous_StressTest_NoUserIdLeak()
606+
{
607+
// Full → Anonymous race: Track reads _state with userId still set,
608+
// then SetConsent flips _state to Anonymous and calls
609+
// ApplyAnonymousDowngrade (one-shot rewrite). If Track's enqueue
610+
// lands after the rewrite, the msg with userId is not stripped.
611+
//
612+
// With the ConsentState + EnqueueChecked transform in place, Track's
613+
// transform runs under _drainLock and strips userId when current state
614+
// is not Full. Zero leaks across all iterations.
615+
//
616+
// Sabotage: remove the `m.Remove(MessageFields.UserId)` in
617+
// EnqueueTrack and this test leaks reproducibly.
618+
const int iterations = 200;
619+
const int trackersPerIteration = 4;
620+
const string testUserId = "user_race_stress";
621+
622+
for (int iter = 0; iter < iterations; iter++)
623+
{
624+
ImmutableAudience.Init(MakeConfig(ConsentLevel.Full));
625+
ImmutableAudience.Identify(testUserId, "steam");
626+
627+
// Clear Init events so only race events can leak.
628+
ImmutableAudience.FlushQueueToDiskForTesting();
629+
var queueDir = AudiencePaths.QueueDir(_testDir);
630+
if (Directory.Exists(queueDir))
631+
foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f);
632+
633+
var barrier = new Barrier(trackersPerIteration + 1);
634+
var trackers = new Task[trackersPerIteration];
635+
for (int t = 0; t < trackersPerIteration; t++)
636+
{
637+
trackers[t] = Task.Run(() =>
638+
{
639+
barrier.SignalAndWait();
640+
ImmutableAudience.Track("race_stress");
641+
});
642+
}
643+
644+
barrier.SignalAndWait();
645+
ImmutableAudience.SetConsent(ConsentLevel.Anonymous);
646+
Task.WaitAll(trackers, TimeSpan.FromSeconds(5));
647+
648+
ImmutableAudience.FlushQueueToDiskForTesting();
649+
650+
int userIdLeaks = 0;
651+
if (Directory.Exists(queueDir))
652+
{
653+
userIdLeaks = Directory.GetFiles(queueDir, "*.json")
654+
.Select(File.ReadAllText)
655+
.Count(c => c.Contains($"\"{testUserId}\""));
656+
}
657+
658+
if (userIdLeaks > 0)
659+
{
660+
Assert.Fail(
661+
$"iteration {iter}: {userIdLeaks} track events retained userId past SetConsent(Anonymous)");
662+
}
663+
664+
ImmutableAudience.ResetState();
665+
if (Directory.Exists(AudiencePaths.AudienceDir(_testDir)))
666+
Directory.Delete(AudiencePaths.AudienceDir(_testDir), recursive: true);
667+
}
668+
}
669+
604670
[Test]
605671
public void ResetState_ClearsIdentityCache_AcrossInitWithDifferentPath()
606672
{

0 commit comments

Comments
 (0)