From 14cda32e2c25f6f78442bb17d4aef60da9460c38 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 29 Apr 2026 21:42:25 +1000 Subject: [PATCH] feat(audience-sdk): add E2E robustness tests and Timer dispose fix (SDK-257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests: - ThreadSafetyStressTests: 4 / 16 thread sustained Track load (5s, no exceptions, queue count matches fired). 16-thread mixed Track / Identify / SetConsent. 10 000-call per-call main-thread allocation budget via GC.GetAllocatedBytesForCurrentThread, isolated from drain-thread noise. - OfflineResilienceTests: Track and Shutdown absorb disk-write IOException without throwing. Memory-only fallback (retain events without losing in-flight) captured as [Ignore]'d test — EventQueue currently drops on IOException; gap documented for follow-up. - PublishableKeyPrefixTests: Init warns on test-prefix + production BaseUrl (and the symmetric pairing). Custom dev/staging URLs not flagged. Track + Flush surfaces backend 401 as ValidationRejected via OnError, body propagated through. - TimerDisposalTests: null, already-disposed, idle, long-running callback (regression guard for the SDK fix below). SDK: - ImmutableAudience.WarnIfKeyEnvironmentMismatch logs at Init when prefix and BaseUrl override contradict. Init proceeds either way. - New TimerDisposal.DisposeAndWait at three call sites (Session.Start, Session.DrainHeartbeatTimer, ImmutableAudience.Shutdown). Replaces a pattern that raced its own dispose — WaitOne timeout disposed the handle, then Timer's signal-when-done fired SetEvent on the closed SafeWaitHandle and crashed the threadpool. Helper leaks the wait handle on timeout; GC reclaims after the signal lands. Test infrastructure: - TestDefaults shared constants (FlushIntervalSeconds, FlushSize) across the new fixtures. Out of scope: - Kill-Unity restart automation — needs CLI Unity test harness. - Network toggle — platform-coupled, requires admin privileges. - EventQueue memory-only fallback for disk-full — separate ticket. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Packages/Audience/Runtime/Core/Session.cs | 33 +-- .../Audience/Runtime/ImmutableAudience.cs | 37 ++- .../Audience/Runtime/Utility/TimerDisposal.cs | 48 ++++ .../Runtime/Utility/TimerDisposal.cs.meta | 11 + .../Tests/Runtime/OfflineResilienceTests.cs | 127 +++++++++ .../Runtime/OfflineResilienceTests.cs.meta | 11 + .../Runtime/PublishableKeyPrefixTests.cs | 182 +++++++++++++ .../Runtime/PublishableKeyPrefixTests.cs.meta | 11 + .../Audience/Tests/Runtime/TestDefaults.cs | 8 + .../Tests/Runtime/TestDefaults.cs.meta | 11 + .../Tests/Runtime/ThreadSafetyStressTests.cs | 246 ++++++++++++++++++ .../Runtime/ThreadSafetyStressTests.cs.meta | 11 + .../Runtime/Utility/TimerDisposalTests.cs | 64 +++++ .../Utility/TimerDisposalTests.cs.meta | 11 + 14 files changed, 774 insertions(+), 37 deletions(-) create mode 100644 src/Packages/Audience/Runtime/Utility/TimerDisposal.cs create mode 100644 src/Packages/Audience/Runtime/Utility/TimerDisposal.cs.meta create mode 100644 src/Packages/Audience/Tests/Runtime/OfflineResilienceTests.cs create mode 100644 src/Packages/Audience/Tests/Runtime/OfflineResilienceTests.cs.meta create mode 100644 src/Packages/Audience/Tests/Runtime/PublishableKeyPrefixTests.cs create mode 100644 src/Packages/Audience/Tests/Runtime/PublishableKeyPrefixTests.cs.meta create mode 100644 src/Packages/Audience/Tests/Runtime/TestDefaults.cs create mode 100644 src/Packages/Audience/Tests/Runtime/TestDefaults.cs.meta create mode 100644 src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs create mode 100644 src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs.meta create mode 100644 src/Packages/Audience/Tests/Runtime/Utility/TimerDisposalTests.cs create mode 100644 src/Packages/Audience/Tests/Runtime/Utility/TimerDisposalTests.cs.meta diff --git a/src/Packages/Audience/Runtime/Core/Session.cs b/src/Packages/Audience/Runtime/Core/Session.cs index 1d16566f..434d4309 100644 --- a/src/Packages/Audience/Runtime/Core/Session.cs +++ b/src/Packages/Audience/Runtime/Core/Session.cs @@ -77,19 +77,8 @@ internal void Start() } } - if (oldTimer != null) - { - using var waited = new ManualResetEvent(false); - try - { - // 500ms budget (double-Start is a misuse path). - if (oldTimer.Dispose(waited)) - waited.WaitOne(TimeSpan.FromMilliseconds(500)); - } - catch (ObjectDisposedException) - { - } - } + // 500ms budget — double-Start is a misuse path. + TimerDisposal.DisposeAndWait(oldTimer, TimeSpan.FromMilliseconds(500)); // Phase 2: populate new state. Re-check _disposed (may have flipped during drain). string sessionId; @@ -286,22 +275,10 @@ private void DrainHeartbeatTimer() } if (timer == null) return; - using var waited = new ManualResetEvent(false); - try - { - // Timer was already disposed. The signal handle won't fire, so - // don't wait for it. - if (!timer.Dispose(waited)) - return; - - if (!waited.WaitOne(TimeSpan.FromSeconds(1))) - { - Log.Warn("Session: heartbeat callback did not complete within 1s on timer stop. " + - "A trailing session_heartbeat may race with the next session lifecycle event."); - } - } - catch (ObjectDisposedException) + if (!TimerDisposal.DisposeAndWait(timer, TimeSpan.FromSeconds(1))) { + Log.Warn("Session: heartbeat callback did not complete within 1s on timer stop. " + + "A trailing session_heartbeat may race with the next session lifecycle event."); } } diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 02ac9310..f346ed71 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -124,6 +124,8 @@ public static void Init(AudienceConfig config) return; } + WarnIfKeyEnvironmentMismatch(config.PublishableKey, config.BaseUrl); + _config = config; Log.Enabled = config.Debug; // Persisted consent overrides the config default (prior downgrade survives restart). @@ -719,16 +721,8 @@ public static void Shutdown() // End session first so session_end hits the queue before the final flush. session?.Dispose(); - // Drain in-flight timer callbacks before disposing dependents. // Parameterless Timer.Dispose would return immediately and race SendBatch. - if (timer != null) - { - using var disposed = new ManualResetEvent(false); - if (timer.Dispose(disposed)) - { - disposed.WaitOne(TimeSpan.FromSeconds(2)); - } - } + TimerDisposal.DisposeAndWait(timer, TimeSpan.FromSeconds(2)); // Clear the gate in case WaitOne timed out with SendBatch still running // — a later Init would otherwise be stranded at 1. @@ -801,6 +795,31 @@ internal static void ResetState() private static Dictionary? SnapshotCallerDict(Dictionary? src) => src != null ? new Dictionary(src) : null; + // Only the exact production/sandbox swap is flagged; custom dev/staging + // URLs are intentional and left alone. + private static void WarnIfKeyEnvironmentMismatch(string publishableKey, string? baseUrlOverride) + { + if (string.IsNullOrEmpty(baseUrlOverride)) return; + + var trimmed = baseUrlOverride!.TrimEnd('/'); + var isTestKey = publishableKey.StartsWith(Constants.TestKeyPrefix); + + if (isTestKey && trimmed == Constants.ProductionBaseUrl) + { + Log.Warn( + $"Publishable key has the test prefix ({Constants.TestKeyPrefix}) but BaseUrl points to production. " + + "The backend will reject events with 401. Either remove the BaseUrl override (test keys " + + "default to sandbox) or use a non-test publishable key."); + } + else if (!isTestKey && trimmed == Constants.SandboxBaseUrl) + { + Log.Warn( + "Publishable key is not a test key but BaseUrl points to sandbox. " + + "The backend will reject events with 401. Either remove the BaseUrl override (non-test " + + $"keys default to production) or use a test publishable key ({Constants.TestKeyPrefix})."); + } + } + // Checks the current consent inside the drain lock. If consent has // since dropped to None the message is discarded. If it dropped to // Anonymous the userId is stripped. diff --git a/src/Packages/Audience/Runtime/Utility/TimerDisposal.cs b/src/Packages/Audience/Runtime/Utility/TimerDisposal.cs new file mode 100644 index 00000000..baeea4c7 --- /dev/null +++ b/src/Packages/Audience/Runtime/Utility/TimerDisposal.cs @@ -0,0 +1,48 @@ +#nullable enable + +using System; +using System.Threading; + +namespace Immutable.Audience +{ + internal static class TimerDisposal + { + // Disposes the timer and waits up to timeout for callbacks to finish. + // - Finished in time: clean up normally. + // - Timed out: leave the wait handle behind. Disposing it would race + // the timer's own cleanup signal and crash the process. The + // garbage collector reclaims it later. + internal static bool DisposeAndWait(Timer? timer, TimeSpan timeout) + { + if (timer is null) return true; + + var handle = new ManualResetEvent(false); + bool ownsHandle = true; + try + { + if (!timer.Dispose(handle)) + { + // Already disposed — no signal to wait for. + return true; + } + + if (handle.WaitOne(timeout)) + { + return true; + } + + // Timeout: leak the handle (see preamble). + ownsHandle = false; + return false; + } + catch (ObjectDisposedException) + { + return true; + } + finally + { + if (ownsHandle) handle.Dispose(); + } + } + } +} diff --git a/src/Packages/Audience/Runtime/Utility/TimerDisposal.cs.meta b/src/Packages/Audience/Runtime/Utility/TimerDisposal.cs.meta new file mode 100644 index 00000000..e3f5ce73 --- /dev/null +++ b/src/Packages/Audience/Runtime/Utility/TimerDisposal.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5e7d9c3a8b4f6e2d1c5b9a7e3f8d2c6b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Tests/Runtime/OfflineResilienceTests.cs b/src/Packages/Audience/Tests/Runtime/OfflineResilienceTests.cs new file mode 100644 index 00000000..351280a4 --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/OfflineResilienceTests.cs @@ -0,0 +1,127 @@ +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + internal class OfflineResilienceTests + { + private string _testDir; + + [SetUp] + public void SetUp() + { + _testDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_testDir); + } + + [TearDown] + public void TearDown() + { + ImmutableAudience.ResetState(); + ImmutableAudience.LaunchContextProvider = null; + ImmutableAudience.ContextProvider = null; + ImmutableAudience.DefaultPersistentDataPathProvider = null; + Identity.Reset(_testDir); + // Restore queue dir (a test may have left it as a file). + var queueDir = AudiencePaths.QueueDir(_testDir); + if (File.Exists(queueDir)) File.Delete(queueDir); + if (Directory.Exists(_testDir)) + Directory.Delete(_testDir, recursive: true); + } + + private class KeepOnDiskHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)); + } + + private AudienceConfig MakeConfig() => new AudienceConfig + { + PublishableKey = "pk_imapik-test-key", + Consent = ConsentLevel.Anonymous, + PersistentDataPath = _testDir, + FlushIntervalSeconds = TestDefaults.FlushIntervalSeconds, + FlushSize = TestDefaults.FlushSize, + HttpHandler = new KeepOnDiskHandler() + }; + + // Cross-platform alternative to chmod/quota/admin: writes inside + // a file (not a directory) fail. + private void BlockDiskWrites() + { + var queueDir = AudiencePaths.QueueDir(_testDir); + // Drain pre-block events so they don't pollute the assertion. + ImmutableAudience.FlushQueueToDiskForTesting(); + if (Directory.Exists(queueDir)) + { + foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); + Directory.Delete(queueDir); + } + File.WriteAllText(queueDir, "blocker"); + } + + // ----------------------------------------------------------------- + // Current-behaviour regression: blocked disk does not crash the SDK + // ----------------------------------------------------------------- + + [Test] + public void Track_DiskWritesBlocked_DoesNotThrowToCallers() + { + ImmutableAudience.Init(MakeConfig()); + BlockDiskWrites(); + + Assert.DoesNotThrow(() => + { + for (int i = 0; i < 50; i++) ImmutableAudience.Track($"blocked_{i}"); + ImmutableAudience.FlushQueueToDiskForTesting(); + }, "Track must not propagate disk-write IOException to callers"); + + Assert.IsTrue(ImmutableAudience.Initialized, + "SDK should remain initialised after a sustained disk-write failure"); + } + + [Test] + public void Shutdown_DiskWritesBlocked_DoesNotThrow() + { + // Shutdown is invoked from app-quit handlers; an exception would + // crash the process. + ImmutableAudience.Init(MakeConfig()); + ImmutableAudience.Track("event_pre_block"); + BlockDiskWrites(); + for (int i = 0; i < 20; i++) ImmutableAudience.Track($"blocked_{i}"); + + Assert.DoesNotThrow(() => ImmutableAudience.Shutdown(), + "Shutdown must absorb disk-write failure during the final drain"); + } + + [Test] + [Ignore("Target behaviour: memory-only fallback without losing in-flight events. " + + "EventQueue currently drops on IOException — remove [Ignore] once it retains.")] + public void Track_DiskWritesBlocked_RetainsEventsInMemory_AndSurfacesOnError() + { + var errors = new ConcurrentBag(); + var config = MakeConfig(); + config.OnError = errors.Add; + + ImmutableAudience.Init(config); + BlockDiskWrites(); + + const int eventCount = 50; + for (int i = 0; i < eventCount; i++) ImmutableAudience.Track($"blocked_{i}"); + ImmutableAudience.FlushQueueToDiskForTesting(); + + Assert.GreaterOrEqual(ImmutableAudience.QueueSize, eventCount, + $"events must be retained when disk writes fail; QueueSize={ImmutableAudience.QueueSize}, expected >= {eventCount}"); + + Assert.IsTrue(errors.Any(), + "OnError must fire at least once when the drain thread cannot reach disk"); + } + } +} diff --git a/src/Packages/Audience/Tests/Runtime/OfflineResilienceTests.cs.meta b/src/Packages/Audience/Tests/Runtime/OfflineResilienceTests.cs.meta new file mode 100644 index 00000000..6be958df --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/OfflineResilienceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8b4c6d2e9f3a5b7c1d8e4f6a2b9c5e7d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Tests/Runtime/PublishableKeyPrefixTests.cs b/src/Packages/Audience/Tests/Runtime/PublishableKeyPrefixTests.cs new file mode 100644 index 00000000..7e329fb6 --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/PublishableKeyPrefixTests.cs @@ -0,0 +1,182 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + internal class PublishableKeyPrefixTests + { + private string _testDir; + + [SetUp] + public void SetUp() + { + _testDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_testDir); + } + + [TearDown] + public void TearDown() + { + ImmutableAudience.ResetState(); + ImmutableAudience.LaunchContextProvider = null; + ImmutableAudience.ContextProvider = null; + ImmutableAudience.DefaultPersistentDataPathProvider = null; + Identity.Reset(_testDir); + if (Directory.Exists(_testDir)) + Directory.Delete(_testDir, recursive: true); + } + + // Fake fixtures on purpose: real keys would be a committed secret. + private const string TestPrefixKey = "pk_imapik-test-fixture"; + private const string NonTestKey = "pk_imapik-fixture"; + private static readonly string ProductionUrl = Constants.ProductionBaseUrl; + private static readonly string SandboxUrl = Constants.SandboxBaseUrl; + + private class UnauthorizedHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized) + { + Content = new StringContent("{\"error\":\"unknown publishable key\"}") + }); + } + + private AudienceConfig MakeConfig(string publishableKey, string baseUrl = null) => + new AudienceConfig + { + PublishableKey = publishableKey, + BaseUrl = baseUrl, + Consent = ConsentLevel.Anonymous, + PersistentDataPath = _testDir, + FlushIntervalSeconds = TestDefaults.FlushIntervalSeconds, + FlushSize = TestDefaults.FlushSize, + HttpHandler = new UnauthorizedHandler() + }; + + // ----------------------------------------------------------------- + // Init-time mismatch warning + // ----------------------------------------------------------------- + + [Test] + public void Init_TestKeyAgainstProductionUrl_LogsMismatchWarning() + { + var lines = new List(); + Log.Writer = lines.Add; + try + { + ImmutableAudience.Init(MakeConfig(TestPrefixKey, ProductionUrl)); + + Assert.That(lines, Has.Some.Contains("test prefix").And.Contains("production"), + "Init must warn when a test-prefix key is paired with the production BaseUrl"); + } + finally { Log.Writer = null; } + } + + [Test] + public void Init_NonTestKeyAgainstSandboxUrl_LogsMismatchWarning() + { + var lines = new List(); + Log.Writer = lines.Add; + try + { + ImmutableAudience.Init(MakeConfig(NonTestKey, SandboxUrl)); + + Assert.That(lines, Has.Some.Contains("not a test key").And.Contains("sandbox"), + "Init must warn when a non-test key is paired with the sandbox BaseUrl"); + } + finally { Log.Writer = null; } + } + + [Test] + public void Init_TestKeyAgainstSandboxUrl_DoesNotWarn() + { + var lines = new List(); + Log.Writer = lines.Add; + try + { + ImmutableAudience.Init(MakeConfig(TestPrefixKey, SandboxUrl)); + + Assert.That(lines.Where(l => l.Contains("BaseUrl")), Is.Empty, + "test-key + sandbox-URL is the canonical pairing — no warning expected"); + } + finally { Log.Writer = null; } + } + + [Test] + public void Init_TestKeyAgainstCustomDevUrl_DoesNotWarn() + { + var lines = new List(); + Log.Writer = lines.Add; + try + { + // Fake fixture URL on purpose. + ImmutableAudience.Init(MakeConfig(TestPrefixKey, "https://api.dev.example.com")); + + Assert.That(lines.Where(l => l.Contains("BaseUrl")), Is.Empty, + "custom BaseUrl must not be flagged as a mismatch"); + } + finally { Log.Writer = null; } + } + + [Test] + public void Init_NoBaseUrlOverride_DoesNotWarn() + { + var lines = new List(); + Log.Writer = lines.Add; + try + { + ImmutableAudience.Init(MakeConfig(TestPrefixKey, baseUrl: null)); + + Assert.That(lines.Where(l => l.Contains("BaseUrl")), Is.Empty, + "no override means no mismatch — no warning expected"); + } + finally { Log.Writer = null; } + } + + [Test] + public void Init_TrailingSlashOnProductionUrl_StillDetectsMismatch() + { + var lines = new List(); + Log.Writer = lines.Add; + try + { + ImmutableAudience.Init(MakeConfig(TestPrefixKey, ProductionUrl + "/")); + + Assert.That(lines, Has.Some.Contains("test prefix").And.Contains("production"), + "trailing slash on the production URL must not bypass the mismatch check"); + } + finally { Log.Writer = null; } + } + + // ----------------------------------------------------------------- + // Runtime: backend 401 surfaces via OnError + // ----------------------------------------------------------------- + + [Test] + public async Task Track_BackendReturns401_SurfacesValidationRejected() + { + var errors = new ConcurrentBag(); + var config = MakeConfig(TestPrefixKey, ProductionUrl); + config.OnError = errors.Add; + + ImmutableAudience.Init(config); + ImmutableAudience.Track("event_against_prod_with_test_key"); + await ImmutableAudience.FlushAsync(); + + Assert.IsTrue(errors.Any(e => e.Code == AudienceErrorCode.ValidationRejected), + $"401 from backend must surface as ValidationRejected via OnError; observed {errors.Count} error(s)"); + Assert.IsTrue(errors.Any(e => e.Message.Contains("401")), + "OnError message should include the 401 status code so the studio can correlate with backend logs"); + Assert.IsTrue(errors.Any(e => e.Message.Contains("unknown publishable key")), + "OnError should propagate the backend's error body so studios can see why"); + } + } +} diff --git a/src/Packages/Audience/Tests/Runtime/PublishableKeyPrefixTests.cs.meta b/src/Packages/Audience/Tests/Runtime/PublishableKeyPrefixTests.cs.meta new file mode 100644 index 00000000..cc693ecf --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/PublishableKeyPrefixTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3c9d8e1a4b7f2c5d6a8e9f0b1d2c3e4f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Tests/Runtime/TestDefaults.cs b/src/Packages/Audience/Tests/Runtime/TestDefaults.cs new file mode 100644 index 00000000..1a20e0cd --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/TestDefaults.cs @@ -0,0 +1,8 @@ +namespace Immutable.Audience.Tests +{ + internal static class TestDefaults + { + internal const int FlushIntervalSeconds = 600; + internal const int FlushSize = 1000; + } +} diff --git a/src/Packages/Audience/Tests/Runtime/TestDefaults.cs.meta b/src/Packages/Audience/Tests/Runtime/TestDefaults.cs.meta new file mode 100644 index 00000000..e621d9f3 --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/TestDefaults.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4f8c2b6e9d3a5f1c7e4b8a9d2c5f6a3b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs b/src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs new file mode 100644 index 00000000..b7c2e3e5 --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + internal class ThreadSafetyStressTests + { + private string _testDir; + + [SetUp] + public void SetUp() + { + _testDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_testDir); + } + + [TearDown] + public void TearDown() + { + ImmutableAudience.ResetState(); + ImmutableAudience.LaunchContextProvider = null; + ImmutableAudience.ContextProvider = null; + ImmutableAudience.DefaultPersistentDataPathProvider = null; + Identity.Reset(_testDir); + if (Directory.Exists(_testDir)) + Directory.Delete(_testDir, recursive: true); + } + + // 503 keeps every event on disk so the queue-count assertion is exact. + private class KeepOnDiskHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)); + } + + private AudienceConfig MakeConfig(ConsentLevel consent = ConsentLevel.Full) => + new AudienceConfig + { + PublishableKey = "pk_imapik-test-stress", + Consent = consent, + PersistentDataPath = _testDir, + FlushIntervalSeconds = TestDefaults.FlushIntervalSeconds, + FlushSize = TestDefaults.FlushSize, + HttpHandler = new KeepOnDiskHandler() + }; + + // ----------------------------------------------------------------- + // Track — sustained throughput across N threads + // ----------------------------------------------------------------- + + [Test] + public void Track_4Threads_SustainedLoad_NoExceptions_QueueCountMatches() => + RunSustainedTrackLoad(threadCount: 4, durationSeconds: 5); + + [Test] + public void Track_16Threads_SustainedLoad_NoExceptions_QueueCountMatches() => + RunSustainedTrackLoad(threadCount: 16, durationSeconds: 5); + + private void RunSustainedTrackLoad(int threadCount, int durationSeconds) + { + ImmutableAudience.Init(MakeConfig()); + + // Drain Init's session_start + game_launch so the on-disk count + // measures only what our threads enqueue. + ImmutableAudience.FlushQueueToDiskForTesting(); + var queueDir = AudiencePaths.QueueDir(_testDir); + foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); + + var threads = new Thread[threadCount]; + var firedPerThread = new int[threadCount]; + var exceptions = new ConcurrentBag(); + var barrier = new Barrier(threadCount + 1); + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(durationSeconds); + + for (int t = 0; t < threadCount; t++) + { + int idx = t; + threads[t] = new Thread(() => + { + try + { + barrier.SignalAndWait(); + int count = 0; + while (DateTime.UtcNow < deadline) + { + ImmutableAudience.Track("stress_track"); + count++; + } + firedPerThread[idx] = count; + } + catch (Exception ex) { exceptions.Add(ex); } + }); + threads[t].Start(); + } + + barrier.SignalAndWait(); + var sw = Stopwatch.StartNew(); + foreach (var th in threads) th.Join(); + sw.Stop(); + + CollectionAssert.IsEmpty(exceptions, + $"sustained Track load must not throw — observed {exceptions.Count} exception(s)"); + + int totalFired = firedPerThread.Sum(); + double perThreadRate = totalFired / (double)threadCount / sw.Elapsed.TotalSeconds; + + TestContext.WriteLine( + $"Track threads={threadCount} fired={totalFired} elapsed={sw.Elapsed.TotalSeconds:F2}s rate={perThreadRate:F0}/sec/thread"); + + Assert.GreaterOrEqual(perThreadRate, 200, + $"sustained Track throughput collapsed below floor — observed {perThreadRate:F0}/sec/thread"); + + ImmutableAudience.FlushQueueToDiskForTesting(); + + int onDisk = Directory.GetFiles(queueDir, "*.json").Length; + Assert.AreEqual(totalFired, onDisk, + $"every Track should land on disk; fired={totalFired} onDisk={onDisk} delta={totalFired - onDisk}"); + } + + // ----------------------------------------------------------------- + // Concurrent SetConsent + Identify + Track + // ----------------------------------------------------------------- + + [Test] + public void TrackIdentifySetConsent_ConcurrentLoad_NoRaceExceptions() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Full)); + + const int durationSeconds = 5; + const int trackerCount = 8; + const int identifierCount = 4; + const int consentCount = 4; + const int totalThreads = trackerCount + identifierCount + consentCount; + + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(durationSeconds); + var exceptions = new ConcurrentBag(); + var barrier = new Barrier(totalThreads); + var threads = new Thread[totalThreads]; + int t = 0; + + for (int i = 0; i < trackerCount; i++) + { + threads[t++] = new Thread(() => + { + try + { + barrier.SignalAndWait(); + while (DateTime.UtcNow < deadline) + ImmutableAudience.Track("mixed_load_track"); + } + catch (Exception ex) { exceptions.Add(ex); } + }); + } + + for (int i = 0; i < identifierCount; i++) + { + int seed = i; + threads[t++] = new Thread(() => + { + try + { + barrier.SignalAndWait(); + int n = 0; + while (DateTime.UtcNow < deadline) + ImmutableAudience.Identify($"player_{seed}_{n++}", IdentityType.Custom); + } + catch (Exception ex) { exceptions.Add(ex); } + }); + } + + for (int i = 0; i < consentCount; i++) + { + bool toFull = i % 2 == 0; + threads[t++] = new Thread(() => + { + try + { + barrier.SignalAndWait(); + while (DateTime.UtcNow < deadline) + ImmutableAudience.SetConsent(toFull ? ConsentLevel.Full : ConsentLevel.Anonymous); + } + catch (Exception ex) { exceptions.Add(ex); } + }); + } + + foreach (var th in threads) th.Start(); + foreach (var th in threads) th.Join(); + + CollectionAssert.IsEmpty(exceptions, + $"concurrent Track / Identify / SetConsent must not throw — observed {exceptions.Count} exception(s)"); + Assert.IsTrue(ImmutableAudience.Initialized, + "SDK should remain initialised after the mixed-workload run"); + + var finalConsent = ImmutableAudience.CurrentConsent; + Assert.That(finalConsent, + Is.EqualTo(ConsentLevel.Full).Or.EqualTo(ConsentLevel.Anonymous), + $"final consent must be one of the values we set, got {finalConsent}"); + } + + // ----------------------------------------------------------------- + // Allocation profile — bounded growth + no Gen 2 churn + // ----------------------------------------------------------------- + + [Test] + public void Track_SteadyState_BoundedMainThreadAllocation() + { + ImmutableAudience.Init(MakeConfig()); + + // Warm up so JIT and one-time allocations are out of the measured window. + for (int i = 0; i < 200; i++) ImmutableAudience.Track("warmup"); + ImmutableAudience.FlushQueueToDiskForTesting(); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + long allocBefore = GC.GetAllocatedBytesForCurrentThread(); + + const int iterations = 10_000; + for (int i = 0; i < iterations; i++) + ImmutableAudience.Track("steady_state"); + + long allocDelta = GC.GetAllocatedBytesForCurrentThread() - allocBefore; + double bytesPerCall = (double)allocDelta / iterations; + + TestContext.WriteLine( + $"Track x{iterations}: main-thread alloc={allocDelta:N0}B ({bytesPerCall:F0}B/call)"); + + // Empirical baseline ~860 B/call (one MessageBuilder dict, sized + // for ~10 message-envelope entries). 2.5 KB/call would indicate + // a regression like retaining state across calls or boxing in + // the hot path; normal evolution that adds an envelope entry + // (~80 B) doesn't trip it. + Assert.Less(bytesPerCall, 2500, + $"main-thread allocation per Track call ({bytesPerCall:F0}B) exceeded budget"); + } + } +} diff --git a/src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs.meta b/src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs.meta new file mode 100644 index 00000000..6d10ade0 --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7a3f5b9e2c8d4a1f9b8e7d6c5a4b3f2e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Tests/Runtime/Utility/TimerDisposalTests.cs b/src/Packages/Audience/Tests/Runtime/Utility/TimerDisposalTests.cs new file mode 100644 index 00000000..7f1f96dd --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/Utility/TimerDisposalTests.cs @@ -0,0 +1,64 @@ +using System; +using System.Threading; +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + internal class TimerDisposalTests + { + [Test] + public void DisposeAndWait_NullTimer_ReturnsTrue() + { + bool result = TimerDisposal.DisposeAndWait(null, TimeSpan.FromMilliseconds(100)); + Assert.IsTrue(result); + } + + [Test] + public void DisposeAndWait_AlreadyDisposedTimer_ReturnsTrue() + { + var timer = new Timer(_ => { }, null, Timeout.Infinite, Timeout.Infinite); + timer.Dispose(); + + bool result = TimerDisposal.DisposeAndWait(timer, TimeSpan.FromMilliseconds(100)); + Assert.IsTrue(result); + } + + [Test] + public void DisposeAndWait_IdleTimer_SignalsBeforeTimeout() + { + var timer = new Timer(_ => { }, null, Timeout.Infinite, Timeout.Infinite); + + bool result = TimerDisposal.DisposeAndWait(timer, TimeSpan.FromSeconds(2)); + Assert.IsTrue(result, "idle timer should signal completion within the budget"); + } + + [Test] + public void DisposeAndWait_LongCallback_ReturnsFalseAndLeaksHandle() + { + using var release = new ManualResetEventSlim(false); + using var callbackEntered = new ManualResetEventSlim(false); + + var timer = new Timer(_ => + { + callbackEntered.Set(); + release.Wait(); + }, null, 0, Timeout.Infinite); + + Assert.IsTrue(callbackEntered.Wait(TimeSpan.FromSeconds(2)), + "timer callback should have started before we attempt dispose"); + + bool result = TimerDisposal.DisposeAndWait(timer, TimeSpan.FromMilliseconds(100)); + Assert.IsFalse(result, "expected timeout while the callback is still running"); + + // Sleep gives the leaked handle a window to be touched. + // If we reach the next line without crashing, the leak held. + release.Set(); + Thread.Sleep(300); + + var fresh = new Timer(_ => { }, null, Timeout.Infinite, Timeout.Infinite); + Assert.IsTrue(TimerDisposal.DisposeAndWait(fresh, TimeSpan.FromSeconds(2)), + "subsequent calls must remain functional after a leak"); + } + } +} diff --git a/src/Packages/Audience/Tests/Runtime/Utility/TimerDisposalTests.cs.meta b/src/Packages/Audience/Tests/Runtime/Utility/TimerDisposalTests.cs.meta new file mode 100644 index 00000000..7aaf44a7 --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/Utility/TimerDisposalTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9f2a8c4b6d1e3f5a7b9c0d8e2f4a6b8c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: