Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 5 additions & 28 deletions src/Packages/Audience/Runtime/Core/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.");
}
}

Expand Down
37 changes: 28 additions & 9 deletions src/Packages/Audience/Runtime/ImmutableAudience.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -801,6 +795,31 @@ internal static void ResetState()
private static Dictionary<string, object>? SnapshotCallerDict(Dictionary<string, object>? src) =>
src != null ? new Dictionary<string, object>(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.
Expand Down
48 changes: 48 additions & 0 deletions src/Packages/Audience/Runtime/Utility/TimerDisposal.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
}
11 changes: 11 additions & 0 deletions src/Packages/Audience/Runtime/Utility/TimerDisposal.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

127 changes: 127 additions & 0 deletions src/Packages/Audience/Tests/Runtime/OfflineResilienceTests.cs
Original file line number Diff line number Diff line change
@@ -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<HttpResponseMessage> 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<AudienceError>();
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");
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading