fix: cap automatic playout refreshes

This commit is contained in:
2026-07-12 19:28:15 +09:00
parent 6e00955f1b
commit df60e09884
16 changed files with 603 additions and 37 deletions

View File

@@ -181,11 +181,145 @@ public sealed class LegacyRefreshSchedulerTests
Assert.Equal(0, delays.CallCount);
}
private static LegacyRefreshScheduler CreateScheduler(ControlledDelay delays) =>
[Fact]
public async Task MaximumOne_StopsBeforeASecondDelayOrRefreshCanBeScheduled()
{
var delays = new ControlledDelay();
var scheduler = CreateScheduler(delays, maximumRefreshCount: 1);
var untrackedCalls = 0;
await Assert.ThrowsAsync<InvalidOperationException>(() =>
scheduler.ExecuteRefreshAsync(
_ =>
{
untrackedCalls++;
return Task.FromResult(true);
},
CancellationToken.None));
Assert.Equal(0, untrackedCalls);
Assert.Throws<InvalidOperationException>(scheduler.MarkRefreshSucceeded);
var firstOpportunity = scheduler.WaitForNextRefreshAsync(
_ => Task.FromResult(true),
null,
CancellationToken.None);
Assert.Equal(TimeSpan.FromSeconds(2), await delays.WaitForCallAsync(0));
delays.Complete(0);
Assert.True(await firstOpportunity);
Assert.True(await scheduler.ExecuteRefreshAndTrackSuccessAsync(
_ => Task.FromResult(true),
static result => result,
CancellationToken.None));
Assert.Equal(1, scheduler.CompletedRefreshCount);
Assert.Equal(1, scheduler.MaximumRefreshCount);
Assert.True(scheduler.HasReachedMaximum);
Assert.False(scheduler.IsMaximumCallbackDrained);
var finalCallback = NewCompletion<bool>();
var drain = scheduler.DrainMaximumCallbackAsync(
cancellationToken => finalCallback.Task.WaitAsync(cancellationToken),
CancellationToken.None);
await Task.Yield();
Assert.False(drain.IsCompleted);
Assert.False(scheduler.IsMaximumCallbackDrained);
finalCallback.SetResult(true);
Assert.True(await drain);
Assert.True(scheduler.IsMaximumCallbackDrained);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
scheduler.WaitForNextRefreshAsync(
_ => Task.FromResult(true),
null,
CancellationToken.None));
var unexpectedRefreshCalls = 0;
await Assert.ThrowsAsync<InvalidOperationException>(() =>
scheduler.ExecuteRefreshAsync(
_ =>
{
unexpectedRefreshCalls++;
return Task.FromResult(true);
},
CancellationToken.None));
Assert.Equal(0, unexpectedRefreshCalls);
Assert.Equal(1, delays.CallCount);
Assert.Throws<InvalidOperationException>(scheduler.MarkRefreshSucceeded);
}
[Fact]
public void NullMaximum_PreservesUnlimitedLegacyScheduling()
{
var scheduler = CreateScheduler(new ControlledDelay());
for (var index = 0; index < 10_000; index++)
{
scheduler.MarkRefreshSucceeded();
}
Assert.Null(scheduler.MaximumRefreshCount);
Assert.Equal(10_000, scheduler.CompletedRefreshCount);
Assert.False(scheduler.HasReachedMaximum);
}
[Fact]
public void ZeroMaximum_DisablesAutomaticRefreshBeforeTheFirstDelay()
{
var scheduler = CreateScheduler(new ControlledDelay(), maximumRefreshCount: 0);
Assert.Equal(0, scheduler.CompletedRefreshCount);
Assert.Equal(0, scheduler.MaximumRefreshCount);
Assert.True(scheduler.HasReachedMaximum);
Assert.True(scheduler.IsMaximumCallbackDrained);
Assert.Throws<InvalidOperationException>(scheduler.MarkRefreshSucceeded);
}
[Fact]
public async Task MaximumCallbackTimeout_DoesNotConfirmTheLimit()
{
var scheduler = CreateScheduler(new ControlledDelay(), maximumRefreshCount: 1);
Assert.True(await scheduler.ExecuteRefreshAndTrackSuccessAsync(
_ => Task.FromResult(true),
static result => result,
CancellationToken.None));
Assert.False(await scheduler.DrainMaximumCallbackAsync(
_ => Task.FromResult(false),
CancellationToken.None));
Assert.True(scheduler.HasReachedMaximum);
Assert.False(scheduler.IsMaximumCallbackDrained);
}
[Fact]
public async Task FailedTrackedRefresh_DoesNotConsumeTheMaximum()
{
var scheduler = CreateScheduler(new ControlledDelay(), maximumRefreshCount: 1);
Assert.False(await scheduler.ExecuteRefreshAndTrackSuccessAsync(
_ => Task.FromResult(false),
static result => result,
CancellationToken.None));
Assert.Equal(0, scheduler.CompletedRefreshCount);
Assert.False(scheduler.HasReachedMaximum);
Assert.False(scheduler.IsMaximumCallbackDrained);
}
[Fact]
public void NegativeMaximum_IsRejected()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new LegacyRefreshScheduler(
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(3),
maximumRefreshCount: -1));
}
private static LegacyRefreshScheduler CreateScheduler(
ControlledDelay delays,
int? maximumRefreshCount = null) =>
new(
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(3),
delays.DelayAsync);
delays.DelayAsync,
maximumRefreshCount);
private static async Task<bool> RunOneRefreshAsync(
LegacyRefreshScheduler scheduler,

View File

@@ -29,6 +29,7 @@ public sealed class PlayoutOptionsLoaderTests
"MBN_STOCK_PLAYOUT_RECONNECT_DELAY_MS",
"MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS",
"MBN_STOCK_PLAYOUT_RECONNECT_ENABLED",
"MBN_STOCK_PLAYOUT_MAXIMUM_AUTOMATIC_REFRESHES_PER_TAKE_IN",
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
"MBN_STOCK_PLAYOUT_TEST_SCENE_ALLOWLIST",
"MBN_STOCK_PLAYOUT_TRUSTED_LIVE_OUTPUT_ENABLED"
@@ -58,6 +59,7 @@ public sealed class PlayoutOptionsLoaderTests
Assert.Empty(options.TestSceneAllowlist);
Assert.False(options.TrustedLiveOutputEnabled);
Assert.True(options.ReconnectEnabled);
Assert.Null(options.MaximumAutomaticRefreshesPerTakeIn);
}
[Fact]
@@ -87,6 +89,7 @@ public sealed class PlayoutOptionsLoaderTests
.Set("MBN_STOCK_PLAYOUT_RECONNECT_DELAY_MS", "1505")
.Set("MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS", "4")
.Set("MBN_STOCK_PLAYOUT_RECONNECT_ENABLED", "false")
.Set("MBN_STOCK_PLAYOUT_MAXIMUM_AUTOMATIC_REFRESHES_PER_TAKE_IN", "999")
.Set("MBN_STOCK_PLAYOUT_TEST_SCENE_ALLOWLIST", "C:\\env\\must-not-apply.t2s")
.Set("MBN_STOCK_PLAYOUT_TRUSTED_LIVE_OUTPUT_ENABLED", "false");
using var file = TemporaryJsonFile.Create(
@@ -110,7 +113,8 @@ public sealed class PlayoutOptionsLoaderTests
"processPollIntervalMilliseconds": 1000,
"reconnectDelayMilliseconds": 1000,
"maximumReconnectAttempts": 3,
"reconnectEnabled": true
"reconnectEnabled": true,
"maximumAutomaticRefreshesPerTakeIn": 1
}
""");
@@ -139,6 +143,7 @@ public sealed class PlayoutOptionsLoaderTests
Assert.Equal(1505, options.ReconnectDelayMilliseconds);
Assert.Equal(4, options.MaximumReconnectAttempts);
Assert.False(options.ReconnectEnabled);
Assert.Equal(1, options.MaximumAutomaticRefreshesPerTakeIn);
Assert.Equal(["C:\\test-scenes\\allowed.t2s"], options.TestSceneAllowlist);
Assert.True(options.TrustedLiveOutputEnabled);
}
@@ -177,6 +182,24 @@ public sealed class PlayoutOptionsLoaderTests
Assert.DoesNotContain("invalid-json", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Load_StringAutomaticRefreshMaximumFailsClosed()
{
using var environment = ClearedEnvironment();
using var file = TemporaryJsonFile.Create(
"""
{
"maximumAutomaticRefreshesPerTakeIn": "1"
}
""");
var exception = Assert.Throws<PlayoutConfigurationException>(
() => PlayoutOptionsLoader.Load(file.Path));
Assert.DoesNotContain(file.Path, exception.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("\"1\"", exception.Message, StringComparison.Ordinal);
}
[Theory]
[InlineData("MBN_STOCK_PLAYOUT_MODE", "not-a-mode")]
[InlineData("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_KIND", "not-a-background")]

View File

@@ -510,6 +510,35 @@ public sealed class PlayoutSafetyValidationTests : IDisposable
Assert.Contains(expectedProperty, exception.Message, StringComparison.Ordinal);
}
[Theory]
[InlineData(-1)]
[InlineData(1_000_001)]
public void Validate_OutOfRangeAutomaticRefreshMaximumIsRejected(int value)
{
var options = ValidTestOptions();
options.MaximumAutomaticRefreshesPerTakeIn = value;
var exception = Assert.Throws<PlayoutConfigurationException>(
() => ValidatedPlayoutOptions.Create(options));
Assert.Contains(
nameof(PlayoutOptions.MaximumAutomaticRefreshesPerTakeIn),
exception.Message,
StringComparison.Ordinal);
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(1_000_000)]
public void Validate_AutomaticRefreshMaximumAcceptsClosedSafeRange(int value)
{
var options = ValidTestOptions();
options.MaximumAutomaticRefreshesPerTakeIn = value;
_ = ValidatedPlayoutOptions.Create(options);
}
public void Dispose() => _scenes.Dispose();
private PlayoutOptions ValidTestOptions() => new()

View File

@@ -158,6 +158,83 @@ test("A database refresh fault clears only after TAKE OUT resets refresh state",
assert.equal(safety.shouldClearRefreshError(unrelated, false), false);
});
test("Refresh budget status requires a closed nonnegative maximum and native drain proof", () => {
assert.deepEqual(safety.normalizeRefreshBudget({}), {
completed: 0,
maximum: null,
limitReached: false
});
assert.deepEqual(safety.normalizeRefreshBudget({
refreshCompletedCount: 1,
refreshMaximumCount: 1,
refreshLimitReached: false
}), {
completed: 1,
maximum: 1,
limitReached: false
});
assert.deepEqual(safety.normalizeRefreshBudget({
refreshCompletedCount: 1,
refreshMaximumCount: 1,
refreshLimitReached: true,
refreshActive: false,
playCompletionPending: false,
refreshFaulted: false
}), {
completed: 1,
maximum: 1,
limitReached: true
});
assert.deepEqual(safety.normalizeRefreshBudget({
refreshCompletedCount: 2,
refreshMaximumCount: -1,
refreshLimitReached: true
}), {
completed: 2,
maximum: null,
limitReached: false
});
for (const contradiction of [
{ refreshActive: true },
{ playCompletionPending: true },
{ refreshFaulted: true }
]) {
assert.equal(safety.normalizeRefreshBudget({
refreshCompletedCount: 1,
refreshMaximumCount: 1,
refreshLimitReached: true,
...contradiction
}).limitReached, false);
}
assert.equal(safety.normalizeRefreshBudget({
refreshCompletedCount: Number.MAX_SAFE_INTEGER,
refreshMaximumCount: 1000001,
refreshLimitReached: true
}).maximum, null);
});
test("Refresh budget labels distinguish active, callback-drained capped, fault and default states", () => {
assert.equal(safety.formatRefreshBudgetState({}), "STOPPED");
assert.equal(safety.formatRefreshBudgetState({
refreshActive: true,
refreshCompletedCount: 0,
refreshMaximumCount: 1
}, "18:00:00"), "ACTIVE · 0/1 · 18:00:00");
assert.equal(safety.formatRefreshBudgetState({
refreshCompletedCount: 1,
refreshMaximumCount: 1,
refreshLimitReached: true,
refreshActive: false,
playCompletionPending: false,
refreshFaulted: false
}), "CAPPED · 1/1");
assert.equal(safety.formatRefreshBudgetState({
refreshFaulted: true,
refreshCompletedCount: 1,
refreshMaximumCount: 1
}), "FAULT");
});
test("Database health snapshots accept only a strictly increasing native sequence", () => {
assert.equal(safety.isNewerDatabaseStatusSequence(0, 1), true);
assert.equal(safety.isNewerDatabaseStatusSequence(7, 8), true);