Skip to content

Commit 81ff2e9

Browse files
test(audience): cancellation exits FlushAsync and SendBatchAsync cleanly
Two regression guards for PR #701 review from @nattb8. SendBatchAsync_CallerCancelled_Throws: pre-cancel the token, confirm the method throws OperationCanceledException, confirm the batch stays on disk, confirm no backoff and no onError. Sabotage: re-add the empty catch body and this fails because SendBatchAsync returns true silently. FlushAsync_CancelledToken_Terminates_DoesNotHotLoop: pre-cancel the token, start FlushAsync, race against a 2s timeout. With the fix the task faults quickly; without it the task never completes. Also flips the handler to 200 and runs a follow-up FlushAsync to prove _sendInFlight was released (the finally block didn't get stranded). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e230be5 commit 81ff2e9

2 files changed

Lines changed: 84 additions & 0 deletions

File tree

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

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1178,6 +1178,63 @@ public void FlushAsync_ConcurrentCallers_OnlyOneReachesTransport()
11781178
"both FlushAsync calls should complete after release");
11791179
}
11801180

1181+
[Test]
1182+
public async Task FlushAsync_CancelledToken_Terminates_DoesNotHotLoop()
1183+
{
1184+
// Regression for PR #701 review (@nattb8): if SendBatchAsync
1185+
// silently swallowed caller cancellation, the inner while-loop
1186+
// here would re-enter on the same cancelled token and spin
1187+
// because the batch is never deleted on that code path. The
1188+
// task below would never complete. After the fix, cancellation
1189+
// propagates and the task faults quickly.
1190+
var handler = new CancellingHandler();
1191+
var config = MakeConfig();
1192+
config.HttpHandler = handler;
1193+
1194+
ImmutableAudience.Init(config);
1195+
ImmutableAudience.Track("event_to_send");
1196+
ImmutableAudience.FlushQueueToDiskForTesting();
1197+
1198+
using var cts = new CancellationTokenSource();
1199+
cts.Cancel();
1200+
1201+
var flush = ImmutableAudience.FlushAsync(cts.Token);
1202+
var finishedFirst = await Task.WhenAny(flush, Task.Delay(TimeSpan.FromSeconds(2)));
1203+
1204+
Assert.AreSame(flush, finishedFirst,
1205+
"FlushAsync must terminate (not hot-loop) when the token is cancelled");
1206+
Assert.IsTrue(flush.IsCanceled || flush.IsFaulted,
1207+
"FlushAsync must propagate the cancellation, not return normally");
1208+
Assert.LessOrEqual(handler.CallCount, 1,
1209+
"a cancelled token must not drive repeated SendAsync attempts");
1210+
1211+
// Gate must be released by the finally block — a follow-up flush
1212+
// on an uncancelled token should proceed, proving _sendInFlight
1213+
// is not stranded at 1.
1214+
handler.AcceptNextAsSuccess = true;
1215+
var followUp = ImmutableAudience.FlushAsync();
1216+
Assert.IsTrue(followUp.Wait(TimeSpan.FromSeconds(2)),
1217+
"_sendInFlight must be released after a cancelled flush");
1218+
}
1219+
1220+
private class CancellingHandler : HttpMessageHandler
1221+
{
1222+
public int CallCount;
1223+
public bool AcceptNextAsSuccess;
1224+
1225+
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
1226+
{
1227+
Interlocked.Increment(ref CallCount);
1228+
ct.ThrowIfCancellationRequested();
1229+
var status = AcceptNextAsSuccess ? HttpStatusCode.OK : HttpStatusCode.ServiceUnavailable;
1230+
var body = AcceptNextAsSuccess ? "{\"accepted\":1,\"rejected\":0}" : "";
1231+
return Task.FromResult(new HttpResponseMessage(status)
1232+
{
1233+
Content = new StringContent(body)
1234+
});
1235+
}
1236+
}
1237+
11811238
private class BlockingHandler : HttpMessageHandler
11821239
{
11831240
public readonly ManualResetEventSlim EnteredSendAsync = new ManualResetEventSlim(false);

src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,33 @@ public async Task SendBatchAsync_HttpClientTimeout_TreatedAsNetworkError()
396396
Assert.AreEqual(AudienceErrorCode.NetworkError, reportedError.Code);
397397
}
398398

399+
[Test]
400+
public void SendBatchAsync_CallerCancelled_Throws_DoesNotDeleteOrRecordFailure()
401+
{
402+
// Regression guard for PR #701 review: caller cancellation must
403+
// propagate. If the `when (ct.IsCancellationRequested)` branch
404+
// swallowed the exception, SendBatchAsync would return `true`
405+
// with the batch still on disk, and a FlushAsync loop watching
406+
// that return value would re-enter on the same cancelled token
407+
// forever — nothing ever drains, nothing ever throws.
408+
_store.Write("{\"type\":\"track\"}");
409+
410+
var handler = new MockHandler(() => throw new OperationCanceledException("simulated"));
411+
AudienceError reportedError = null;
412+
using var transport = new HttpTransport(_store, "pk_imapik-test-key1",
413+
onError: e => reportedError = e, handler: handler);
414+
415+
using var cts = new CancellationTokenSource();
416+
cts.Cancel();
417+
418+
Assert.ThrowsAsync<OperationCanceledException>(
419+
async () => await transport.SendBatchAsync(cts.Token));
420+
421+
Assert.AreEqual(1, _store.Count(), "cancelled send must not delete the batch");
422+
Assert.IsFalse(transport.IsInBackoffWindow, "cancel is not a failure — no backoff engaged");
423+
Assert.IsNull(reportedError, "cancel is caller-initiated — no onError fires");
424+
}
425+
399426
[Test]
400427
public async Task IsInBackoffWindow_ClearsAfterNextAttemptAtElapses()
401428
{

0 commit comments

Comments
 (0)