diff --git a/Config/playout.example.json b/Config/playout.example.json
index 0429548..ba93de0 100644
--- a/Config/playout.example.json
+++ b/Config/playout.example.json
@@ -23,5 +23,6 @@
"processPollIntervalMilliseconds": 1000,
"reconnectDelayMilliseconds": 1000,
"maximumReconnectAttempts": 3,
- "reconnectEnabled": true
+ "reconnectEnabled": true,
+ "maximumAutomaticRefreshesPerTakeIn": null
}
diff --git a/MBN_STOCK_WEBVIEW.csproj b/MBN_STOCK_WEBVIEW.csproj
index fc92b85..0a58ea3 100644
--- a/MBN_STOCK_WEBVIEW.csproj
+++ b/MBN_STOCK_WEBVIEW.csproj
@@ -25,9 +25,9 @@
Properties\PublishProfiles\win-x64.pubxml
false
false
- 1.0.4
- 5
- 1.0.4
+ 1.0.5
+ 6
+ 1.0.5
diff --git a/MainWindow.Playout.cs b/MainWindow.Playout.cs
index 09a05aa..3118b04 100644
--- a/MainWindow.Playout.cs
+++ b/MainWindow.Playout.cs
@@ -39,6 +39,7 @@ public sealed partial class MainWindow
_legacyCompositionOptionsSource =
new MutableLegacySceneCueCompositionOptionsSource(compositionOptions);
_playoutEngine = PlayoutEngineFactory.Create(options);
+ ResetLegacyRefreshStatus();
_playoutEngine.StatusChanged += OnPlayoutStatusChanged;
ObserveNativeTornadoState(_playoutEngine.Status.State);
if (_databaseRuntime is not null)
@@ -316,7 +317,10 @@ public sealed partial class MainWindow
refreshLastSuccessAt = refreshStatus.LastSuccessAtUtc,
refreshFaulted = refreshStatus.IsFaulted,
refreshFaultCode = refreshStatus.FaultCode,
- refreshFaultMessage = refreshStatus.FaultMessage
+ refreshFaultMessage = refreshStatus.FaultMessage,
+ refreshCompletedCount = refreshStatus.CompletedRefreshCount,
+ refreshMaximumCount = refreshStatus.MaximumRefreshCount,
+ refreshLimitReached = refreshStatus.IsLimitReached
});
}
catch (OperationCanceledException)
@@ -499,6 +503,9 @@ public sealed partial class MainWindow
refreshFaulted = false,
refreshFaultCode = (string?)null,
refreshFaultMessage = (string?)null,
+ refreshCompletedCount = 0,
+ refreshMaximumCount = (int?)null,
+ refreshLimitReached = false,
changedAt
});
}
@@ -546,6 +553,9 @@ public sealed partial class MainWindow
refreshFaulted = false,
refreshFaultCode = (string?)null,
refreshFaultMessage = (string?)null,
+ refreshCompletedCount = 0,
+ refreshMaximumCount = (int?)null,
+ refreshLimitReached = false,
changedAt
});
}
@@ -606,6 +616,9 @@ public sealed partial class MainWindow
refreshFaulted = refreshStatus.IsFaulted,
refreshFaultCode = refreshStatus.FaultCode,
refreshFaultMessage = refreshStatus.FaultMessage,
+ refreshCompletedCount = refreshStatus.CompletedRefreshCount,
+ refreshMaximumCount = refreshStatus.MaximumRefreshCount,
+ refreshLimitReached = refreshStatus.IsLimitReached,
changedAt = status.ChangedAtUtc
});
}
@@ -661,6 +674,9 @@ public sealed partial class MainWindow
refreshFaulted = refreshStatus.IsFaulted,
refreshFaultCode = refreshStatus.FaultCode,
refreshFaultMessage = refreshStatus.FaultMessage,
+ refreshCompletedCount = refreshStatus.CompletedRefreshCount,
+ refreshMaximumCount = refreshStatus.MaximumRefreshCount,
+ refreshLimitReached = refreshStatus.IsLimitReached,
changedAt = status.ChangedAtUtc
};
}
@@ -780,6 +796,7 @@ public sealed partial class MainWindow
private void StartLegacyRefreshLoop(LegacyPlayoutWorkflow workflow)
{
StopLegacyRefreshLoop();
+ ResetLegacyRefreshStatus();
var cutCode = workflow.State.OnAirCutCode;
if (!LegacySceneRefreshPolicy.TryGetInterval(cutCode, out var interval) ||
interval <= TimeSpan.Zero ||
@@ -792,16 +809,38 @@ public sealed partial class MainWindow
_lifetimeCancellation.Token);
var scheduler = new LegacyRefreshScheduler(
interval,
- LegacySceneRefreshPolicy.RecurringInterval);
- _legacyRefreshEpoch.Replace(
- cancellation,
- status => new LegacyRefreshRuntimeStatus(
- true,
- null,
- status.LastSuccessAtUtc,
+ LegacySceneRefreshPolicy.RecurringInterval,
+ _playoutOptions?.MaximumAutomaticRefreshesPerTakeIn);
+ if (scheduler.HasReachedMaximum)
+ {
+ cancellation.Dispose();
+ _legacyRefreshTask = null;
+ _legacyRefreshEpoch.Stop(_ => new LegacyRefreshRuntimeStatus(
false,
null,
- null));
+ null,
+ false,
+ null,
+ null,
+ scheduler.CompletedRefreshCount,
+ scheduler.MaximumRefreshCount,
+ scheduler.IsMaximumCallbackDrained));
+ QueuePlayoutStatus();
+ return;
+ }
+
+ _legacyRefreshEpoch.Replace(
+ cancellation,
+ _ => new LegacyRefreshRuntimeStatus(
+ true,
+ null,
+ null,
+ false,
+ null,
+ null,
+ scheduler.CompletedRefreshCount,
+ scheduler.MaximumRefreshCount,
+ false));
_legacyRefreshTask = RunLegacyRefreshLoopAsync(
workflow,
scheduler,
@@ -836,6 +875,36 @@ public sealed partial class MainWindow
return;
}
+ if (scheduler.HasReachedMaximum)
+ {
+ if (!await scheduler.DrainMaximumCallbackAsync(
+ cancellationToken => WaitForPlayCompletionAsync(
+ engine,
+ cancellationToken),
+ token))
+ {
+ TrySetLegacyRefreshFault(
+ cancellation,
+ "PLAY_CALLBACK_TIMEOUT",
+ "The configured automatic refresh limit was reached, but the final Tornado play callback was not observed in time. Verify PGM output before TAKE OUT.");
+ QueuePlayoutStatus();
+ return;
+ }
+
+ _legacyRefreshEpoch.TryUpdate(
+ cancellation,
+ status => status with
+ {
+ IsActive = false,
+ NextAtUtc = null,
+ CompletedRefreshCount = scheduler.CompletedRefreshCount,
+ MaximumRefreshCount = scheduler.MaximumRefreshCount,
+ IsLimitReached = scheduler.IsMaximumCallbackDrained
+ });
+ QueuePlayoutStatus();
+ return;
+ }
+
// The original WinForms timer coalesced non-reentrant UI ticks but did
// not wait for the vendor callback. The migration's safety invariant is
// stricter: observe OnScenePlayed and then start a complete one-shot
@@ -891,8 +960,9 @@ public sealed partial class MainWindow
CancelActiveDatabaseHealthCheck();
await _databaseActivityGate.WaitAsync(token);
databaseActivityEntered = true;
- var result = await scheduler.ExecuteRefreshAsync(
+ var result = await scheduler.ExecuteRefreshAndTrackSuccessAsync(
workflow.RefreshOnAirAsync,
+ static result => result.IsSuccess,
token);
// A failed, timed-out, or unknown update never repeats. The operator
// must reconcile native/PGM state before starting another command.
@@ -908,7 +978,6 @@ public sealed partial class MainWindow
// MainForm changes timer1.Interval to 3000 after the first
// successful same-scene refresh. The next interval does not
// begin until this Play's completion callback is observed.
- scheduler.MarkRefreshSucceeded();
if (!_legacyRefreshEpoch.TryUpdate(
cancellation,
_ => new LegacyRefreshRuntimeStatus(
@@ -917,7 +986,10 @@ public sealed partial class MainWindow
DateTimeOffset.UtcNow,
false,
null,
- null)))
+ null,
+ scheduler.CompletedRefreshCount,
+ scheduler.MaximumRefreshCount,
+ false)))
{
return;
}
@@ -1056,10 +1128,16 @@ public sealed partial class MainWindow
status.LastSuccessAtUtc,
true,
code,
- message));
+ message,
+ status.CompletedRefreshCount,
+ status.MaximumRefreshCount,
+ status.IsLimitReached));
private void ResetLegacyRefreshStatus() =>
- _legacyRefreshEpoch.Stop(_ => LegacyRefreshRuntimeStatus.Empty);
+ _legacyRefreshEpoch.Stop(_ => LegacyRefreshRuntimeStatus.Empty with
+ {
+ MaximumRefreshCount = _playoutOptions?.MaximumAutomaticRefreshesPerTakeIn
+ });
///
/// Provides the single fail-closed playout boundary used by every operator-data
@@ -1158,7 +1236,10 @@ public sealed partial class MainWindow
DateTimeOffset? LastSuccessAtUtc,
bool IsFaulted,
string? FaultCode,
- string? FaultMessage)
+ string? FaultMessage,
+ int CompletedRefreshCount,
+ int? MaximumRefreshCount,
+ bool IsLimitReached)
{
public static LegacyRefreshRuntimeStatus Empty { get; } = new(
false,
@@ -1166,6 +1247,9 @@ public sealed partial class MainWindow
null,
false,
null,
- null);
+ null,
+ 0,
+ null,
+ false);
}
}
diff --git a/Package.appxmanifest b/Package.appxmanifest
index 0081f9a..6f20d6f 100644
--- a/Package.appxmanifest
+++ b/Package.appxmanifest
@@ -10,7 +10,7 @@
+ Version="1.0.5.0" />
diff --git a/Web/app.js b/Web/app.js
index 218977c..cd817ec 100644
--- a/Web/app.js
+++ b/Web/app.js
@@ -268,11 +268,15 @@
nextKind: "none",
previewFields: [],
refreshActive: false,
+ playCompletionPending: false,
refreshNextAt: null,
refreshLastSuccessAt: null,
refreshFaulted: false,
refreshFaultCode: null,
refreshFaultMessage: null,
+ refreshCompletedCount: 0,
+ refreshMaximumCount: null,
+ refreshLimitReached: false,
changedAt: null,
changedAtMs: 0,
latestStatusRequestId: null,
@@ -6592,6 +6596,7 @@
function applyRefreshState(payload) {
state.playout.refreshActive = payload.refreshActive === true;
+ state.playout.playCompletionPending = payload.playCompletionPending === true;
state.playout.refreshNextAt = payload.refreshNextAt || null;
state.playout.refreshLastSuccessAt = payload.refreshLastSuccessAt || null;
state.playout.refreshFaulted = payload.refreshFaulted === true;
@@ -6601,6 +6606,10 @@
state.playout.refreshFaultMessage = typeof payload.refreshFaultMessage === "string"
? payload.refreshFaultMessage
: null;
+ const refreshBudget = playoutSafety.normalizeRefreshBudget(payload);
+ state.playout.refreshCompletedCount = refreshBudget.completed;
+ state.playout.refreshMaximumCount = refreshBudget.maximum;
+ state.playout.refreshLimitReached = refreshBudget.limitReached;
if (state.playout.refreshFaulted && state.playout.error?.outcomeUnknown !== true) {
state.playout.error = {
code: state.playout.refreshFaultCode || "REFRESH_FAULT",
@@ -6814,11 +6823,12 @@
elements.playoutLastPage.textContent = state.playout.isLastPage ? "YES" : "NO";
elements.playoutNextKind.textContent = state.playout.nextKind.replaceAll("-", " ").toUpperCase();
const refreshNextMs = parseChangedAt(state.playout.refreshNextAt);
- elements.playoutRefreshState.textContent = state.playout.refreshFaulted
- ? "FAULT"
- : (state.playout.refreshActive
- ? `ACTIVE · ${refreshNextMs ? new Date(refreshNextMs).toLocaleTimeString("ko-KR", { hour12: false }) : "WAIT"}`
- : "STOPPED");
+ const refreshNextLabel = refreshNextMs
+ ? new Date(refreshNextMs).toLocaleTimeString("ko-KR", { hour12: false })
+ : "WAIT";
+ elements.playoutRefreshState.textContent = playoutSafety.formatRefreshBudgetState(
+ state.playout,
+ refreshNextLabel);
renderSceneDataPreview();
elements.playoutSummary.classList.remove("ready", "dry-run", "error");
diff --git a/Web/playout-safety.js b/Web/playout-safety.js
index e886ca2..380ed6a 100644
--- a/Web/playout-safety.js
+++ b/Web/playout-safety.js
@@ -77,6 +77,41 @@
return !refreshFaulted && error?.isRefreshFault === true;
}
+ function normalizeRefreshBudget(payload) {
+ const completed = Number.isSafeInteger(payload?.refreshCompletedCount) &&
+ payload.refreshCompletedCount >= 0 &&
+ payload.refreshCompletedCount <= 2147483647
+ ? payload.refreshCompletedCount
+ : 0;
+ const maximum = Number.isSafeInteger(payload?.refreshMaximumCount) &&
+ payload.refreshMaximumCount >= 0 &&
+ payload.refreshMaximumCount <= 1000000
+ ? payload.refreshMaximumCount
+ : null;
+ return {
+ completed,
+ maximum,
+ limitReached: maximum !== null && completed >= maximum &&
+ payload?.refreshLimitReached === true &&
+ payload?.refreshActive !== true &&
+ payload?.playCompletionPending !== true &&
+ payload?.refreshFaulted !== true
+ };
+ }
+
+ function formatRefreshBudgetState(payload, nextLabel = "WAIT") {
+ const budget = normalizeRefreshBudget(payload);
+ const count = budget.maximum === null
+ ? String(budget.completed)
+ : `${budget.completed}/${budget.maximum}`;
+ if (payload?.refreshFaulted === true) return "FAULT";
+ if (budget.limitReached) return `CAPPED · ${count}`;
+ if (payload?.refreshActive === true) return `ACTIVE · ${count} · ${nextLabel}`;
+ return budget.maximum === null && budget.completed === 0
+ ? "STOPPED"
+ : `STOPPED · ${count}`;
+ }
+
function isNewerDatabaseStatusSequence(latestSequence, incomingSequence) {
const latest = Number.isSafeInteger(latestSequence) && latestSequence >= 0
? latestSequence
@@ -107,6 +142,8 @@
canClearCommandError,
playlistSnapshotLocked,
shouldClearRefreshError,
+ normalizeRefreshBudget,
+ formatRefreshBudgetState,
isNewerDatabaseStatusSequence,
resolveStoredCatalogIndex
};
diff --git a/docs/PLAYOUT.md b/docs/PLAYOUT.md
index 5861dc0..e16da06 100644
--- a/docs/PLAYOUT.md
+++ b/docs/PLAYOUT.md
@@ -106,6 +106,7 @@ SDK가 기본 위치에 없다면 x64 SDK의 `TlbImp.exe` 절대 경로를 `-Tlb
| `*TimeoutMilliseconds` | 연결, 작업 및 해제 제한 시간 |
| `processPollIntervalMilliseconds` | `Tornado2` 접두사 프로세스 감시 주기 |
| `reconnect*` | 재연결 지연, 최대 횟수 및 활성화 여부 |
+| `maximumAutomaticRefreshesPerTakeIn` | trusted local 자동 갱신 상한. `null`은 원본의 연속 갱신, `0`은 자동 갱신 비활성화, 양수는 TAKE IN/playlist NEXT로 시작한 epoch당 성공 dispatch 수 상한. 마지막 refresh의 `OnScenePlayed`까지 drain된 뒤 `CAPPED`로 보고 |
JSON보다 다음 환경 변수가 우선합니다.
@@ -134,7 +135,7 @@ MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS
MBN_STOCK_PLAYOUT_RECONNECT_ENABLED
```
-`testSceneAllowlist`와 `trustedLiveOutputEnabled`는 환경 변수로 변경할 수 없으며 로컬 설정 파일에서만 관리합니다. 이름은 기존 설정 호환을 위해 유지하지만 allowlist는 Test뿐 아니라 Live의 PREPARE, TAKE IN 재검사, NEXT와 timer refresh에도 적용되고 비어 있으면 실제 모드를 시작하지 않습니다. 공통 background/fade도 Web payload가 아니라 이 trusted 프로세스 설정 경계에서만 결정합니다. `PlayoutSceneCompositionFactory`는 첫 DryRun 또는 실제 PREPARE 전에 background asset의 상대 경로, 허용 확장자, 존재 여부와 reparse ancestry를 검사하며 실제 경로는 Web status/preview에 노출하지 않습니다. `SceneDirectory`는 Test/Live에서 존재하는 비-reparse 외부 디렉터리여야 하며, 엔진은 상대 `.t2s` 파일을 정규화해 이 루트 밖으로 나가는 경로를 거부합니다. scene file의 basename과 scene name 및 allowlist 항목도 서로 일치해야 합니다. 설정 파일은 실행 계정만 읽을 수 있도록 ACL을 제한합니다. 라이선스 키나 인증정보를 이 파일에 기록하지 않습니다.
+`testSceneAllowlist`, `trustedLiveOutputEnabled`, `maximumAutomaticRefreshesPerTakeIn`은 환경 변수로 변경할 수 없으며 로컬 설정 파일에서만 관리합니다. 이름은 기존 설정 호환을 위해 유지하지만 allowlist는 Test뿐 아니라 Live의 PREPARE, TAKE IN 재검사, NEXT와 timer refresh에도 적용되고 비어 있으면 실제 모드를 시작하지 않습니다. 자동 갱신 상한은 Web payload나 환경 변수로 늘릴 수 없고 COM dispatch 경계에서 다시 검사합니다. 공통 background/fade도 Web payload가 아니라 이 trusted 프로세스 설정 경계에서만 결정합니다. `PlayoutSceneCompositionFactory`는 첫 DryRun 또는 실제 PREPARE 전에 background asset의 상대 경로, 허용 확장자, 존재 여부와 reparse ancestry를 검사하며 실제 경로는 Web status/preview에 노출하지 않습니다. `SceneDirectory`는 Test/Live에서 존재하는 비-reparse 외부 디렉터리여야 하며, 엔진은 상대 `.t2s` 파일을 정규화해 이 루트 밖으로 나가는 경로를 거부합니다. scene file의 basename과 scene name 및 allowlist 항목도 서로 일치해야 합니다. 설정 파일은 실행 계정만 읽을 수 있도록 ACL을 제한합니다. 라이선스 키나 인증정보를 이 파일에 기록하지 않습니다.
### KTAP 포트와 Network Monitoring 판정
diff --git a/docs/PLAYOUT_OPERATIONS.md b/docs/PLAYOUT_OPERATIONS.md
index 2cb8968..16dc7bf 100644
--- a/docs/PLAYOUT_OPERATIONS.md
+++ b/docs/PLAYOUT_OPERATIONS.md
@@ -69,6 +69,12 @@ BYE success marker가 0인 것은 계획의 허용 범위 0~1 안이며, 앱의
Round H 전에 Core 790, Infrastructure 65, Playout 374개 테스트를 Debug/Release x64에서 각각 모두 통과했고, 실제 Oracle/MariaDB read-only query 55건, Visual Studio 2026 Debug/Release x64와 package build, 서명·설치·package context 검사를 통과했다. 같은 signed MSIX의 package-context DryRun도 `5001 PREPARE → TAKE IN → 자동 refresh 정확히 1회 → 5074 page 1 → page 2 → TAKE OUT`과 최종 IDLE, `OutcomeUnknown=false`, KTAP 미호출을 확인했다.
+### Round I 회귀 실패와 native 자동 갱신 상한
+
+Round I `MBNWEB-20260712-I`는 계획 SHA-256 `D221841A15593A987FB944648F0ACE0AC5940936AEE16C74DC9B901A2F45B332`로 CONNECT 1회와 PREPARE 5001/page 1, TAKE IN 1회를 실행했다. 5001 삼성전자 PGM 표시는 확인했지만, 실시간 관찰 자동화가 TAKE OUT까지 하나의 중단 없는 작업으로 묶이지 않아 승인된 자동 refresh 최대 1회를 넘어 9회가 실행됐다. 최종 누적 PLAY/`OnScenePlayed`는 각각 10회(초기 TAKE IN 1 + refresh 9)였다. NEXT와 다른 scene LOAD는 없었고 FAILURE/ERROR는 0이었다. 승인된 TAKE OUT 1회로 STOPALL/UNLOAD 5001 각 1회 성공, PGM black, BYE request 1, 앱 정상 종료를 확인했다. 회차 결과는 `FAIL`, `OutcomeUnknown=false`, vendor command retry 0이며 결과 SHA-256은 `E5FD13E7FCDF87B6ADCFB696EFF2E957B70ECA821A424FC15EE41043CFA0E37E`이다.
+
+이 실패 뒤 `maximumAutomaticRefreshesPerTakeIn` trusted JSON-only 상한을 추가했다. 기본 `null`은 원본 연속 갱신을 유지하고, 실제 검증 설정의 `1`은 scheduler와 실제 refresh dispatch 경계에서 두 번째 실행을 이중 차단한다. 상태는 `refreshCompletedCount`, `refreshMaximumCount`, `refreshLimitReached`를 제공하며, 마지막 refresh `OnScenePlayed`가 drain되기 전에는 count가 상한에 도달해도 `refreshLimitReached=false`를 유지한다. 검증은 `completed=1`, `maximum=1`, `limitReached=true`, `refreshActive=false`, `playCompletionPending=false`를 모두 확인해야 한다. 변경된 package는 Round I 승인/계획을 재사용할 수 없으며 새 package hash, 새 회차 계획과 새 CONNECT/PREPARE/TAKE IN 승인이 필요하다.
+
이 실제 Live PGM 증거는 회차에 허용된 5001과 5074에 한정된다. 35개 scene builder 전체 동등성은 자동 테스트, 55-query 실데이터 smoke와 [`SCENE_EQUIVALENCE.md`](SCENE_EQUIVALENCE.md) 매트릭스로 유지하며, 나머지 scene이 실제 PGM에서 각각 송출됐다는 의미로 기록하지 않는다.
## 화면/계약과 운영 검증의 분리
@@ -222,9 +228,10 @@ PREPARE는 다음 native 순서를 수행해야 한다.
### 5. Timer refresh
- 승인 범위에 포함된 경우에만 자동 refresh를 관찰한다. 현재 안전 스케줄러는 직전 `OnScenePlayed` callback이 lifecycle queue에서 drain된 뒤에만 one-shot cooldown을 시작한다. 5001의 첫 cooldown은 온전한 2초이고, 성공한 refresh 뒤에는 callback drain 후 온전한 3초 recurring cooldown이다. 따라서 실제 dispatch 간격은 callback/drain 시간과 cooldown의 합이며, 원본의 절대 3초 cadence보다 의도적으로 길다.
+- 실제 검증 회차는 trusted JSON에 `maximumAutomaticRefreshesPerTakeIn`을 승인 횟수와 동일하게 고정한다. Web과 환경 변수는 이 값을 변경할 수 없으며, 상한 도달 후 마지막 callback까지 drain되면 loop가 fault 없이 `CAPPED`로 끝난다.
- 이 callback-aware cooldown은 recurring refresh를 제거한 것이 아니다. missed tick을 누적하거나 따라잡지 않고 합치며, refresh는 최대 하나만 in-flight다. 각 실행은 current entry/page의 fresh DB DTO를 사용해 retained on-air scene에 `BeginTransaction` → mutation → `QueryVariables` → `EndTransaction` → `Prepare(10)` → `Play(10)`을 한 번 수행한다.
- timer refresh는 scene-level background와 fade effect를 다시 적용하지 않는다.
-- 앱의 `refreshActive`, `refreshNextAt`, `refreshLastSuccessAt`, fault code/message와 PGM 데이터를 함께 기록한다.
+- 앱의 `refreshActive`, `refreshNextAt`, `refreshLastSuccessAt`, `refreshCompletedCount`, `refreshMaximumCount`, `refreshLimitReached`, fault code/message와 PGM 데이터를 함께 기록한다.
- refresh가 실패하거나 timeout/unknown이면 fault latch가 켜지고 반복이 즉시 끝나야 한다. TAKE OUT 이외의 명령을 시도하지 않는다.
- 명확히 성공한 TAKE OUT 뒤 native refresh state가 reset되면 전용 refresh fault marker만 사라져야 한다. `OutcomeUnknown`, `WEB_TIMEOUT` 또는 native quarantine 표시는 함께 reset되면 안 된다.
- operator NEXT는 먼저 현재 refresh epoch를 원자적으로 stop한다. 그 시점에 Play callback이 pending이면 DB activity 대기나 scene query 전에 `PLAY_CALLBACK_PENDING`으로 즉시 fail-fast하고 자동 재시도하지 않는다. 명령 gate가 바로 풀리므로 TAKE OUT은 계속 사용할 수 있다. callback이 완료된 cooldown 구간에서는 NEXT를 정상 실행할 수 있다.
diff --git a/scripts/Test-WebPlayout.ps1 b/scripts/Test-WebPlayout.ps1
index fd69a3c..42e9cac 100644
--- a/scripts/Test-WebPlayout.ps1
+++ b/scripts/Test-WebPlayout.ps1
@@ -89,6 +89,7 @@ foreach ($script in $scripts) {
}
$appScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Web\app.js') -Raw -Encoding UTF8
+$playoutSafetyScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Web\playout-safety.js') -Raw -Encoding UTF8
$indexMarkup = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Web\index.html') -Raw -Encoding UTF8
$namedPlaylistScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Web\named-playlist-workflow.js') -Raw -Encoding UTF8
$mainWindowScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'MainWindow.xaml.cs') -Raw -Encoding UTF8
@@ -97,6 +98,26 @@ $pagePlanBridgeScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Mai
$legacyWorkflowScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.Core\Playout\Scenes\LegacyPlayoutWorkflow.cs') -Raw -Encoding UTF8
$backgroundScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'MainWindow.Background.cs') -Raw -Encoding UTF8
$playoutMainScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'MainWindow.Playout.cs') -Raw -Encoding UTF8
+$playoutExample = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Config\playout.example.json') -Raw -Encoding UTF8 |
+ ConvertFrom-Json
+if ($playoutExample.PSObject.Properties.Name -notcontains 'maximumAutomaticRefreshesPerTakeIn' -or
+ $null -ne $playoutExample.maximumAutomaticRefreshesPerTakeIn) {
+ throw 'The example playout configuration must preserve the unlimited null compatibility default.'
+}
+if ($playoutMainScript -notmatch 'MaximumAutomaticRefreshesPerTakeIn' -or
+ $playoutMainScript -notmatch 'scheduler\.HasReachedMaximum' -or
+ $playoutMainScript -notmatch 'ExecuteRefreshAndTrackSuccessAsync' -or
+ $playoutMainScript -notmatch 'DrainMaximumCallbackAsync' -or
+ $playoutMainScript -notmatch 'refreshCompletedCount' -or
+ $playoutMainScript -notmatch 'refreshMaximumCount' -or
+ $playoutMainScript -notmatch 'refreshLimitReached') {
+ throw 'The trusted automatic-refresh ceiling and final callback-drain status are not fully wired.'
+}
+if ($appScript -notmatch 'normalizeRefreshBudget' -or
+ $appScript -notmatch 'formatRefreshBudgetState' -or
+ $playoutSafetyScript -notmatch 'CAPPED') {
+ throw 'The Web operator surface does not expose the native automatic-refresh ceiling.'
+}
$sceneCatalogMatches = [regex]::Matches(
$appScript,
'builderKey:\s*"s[0-9]+"')
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyRefreshScheduler.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyRefreshScheduler.cs
index 96d0811..fdcce52 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyRefreshScheduler.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyRefreshScheduler.cs
@@ -12,33 +12,59 @@ public sealed class LegacyRefreshScheduler
{
private readonly TimeSpan _recurringDelay;
private readonly Func _delayAsync;
+ private readonly int? _maximumRefreshCount;
private TimeSpan _nextDelay;
private int _refreshInFlight;
+ private int _completedRefreshCount;
+ private int _maximumCallbackDrained;
- public LegacyRefreshScheduler(TimeSpan initialDelay, TimeSpan recurringDelay)
+ public LegacyRefreshScheduler(
+ TimeSpan initialDelay,
+ TimeSpan recurringDelay,
+ int? maximumRefreshCount = null)
: this(
initialDelay,
recurringDelay,
- static (delay, cancellationToken) => Task.Delay(delay, cancellationToken))
+ static (delay, cancellationToken) => Task.Delay(delay, cancellationToken),
+ maximumRefreshCount)
{
}
internal LegacyRefreshScheduler(
TimeSpan initialDelay,
TimeSpan recurringDelay,
- Func delayAsync)
+ Func delayAsync,
+ int? maximumRefreshCount = null)
{
ValidateDelay(initialDelay, nameof(initialDelay));
ValidateDelay(recurringDelay, nameof(recurringDelay));
ArgumentNullException.ThrowIfNull(delayAsync);
+ if (maximumRefreshCount is < 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(maximumRefreshCount),
+ "A legacy refresh limit cannot be negative.");
+ }
_nextDelay = initialDelay;
_recurringDelay = recurringDelay;
_delayAsync = delayAsync;
+ _maximumRefreshCount = maximumRefreshCount;
+ _maximumCallbackDrained = maximumRefreshCount == 0 ? 1 : 0;
}
public bool IsRefreshInFlight => Volatile.Read(ref _refreshInFlight) != 0;
+ public int CompletedRefreshCount => Volatile.Read(ref _completedRefreshCount);
+
+ public int? MaximumRefreshCount => _maximumRefreshCount;
+
+ public bool HasReachedMaximum =>
+ _maximumRefreshCount is { } maximum && CompletedRefreshCount >= maximum;
+
+ public bool IsMaximumCallbackDrained =>
+ Volatile.Read(ref _maximumCallbackDrained) != 0;
+
///
/// Waits for the prior Play callback first, then starts the entire one-shot
/// delay. A false callback result prevents any delay or refresh dispatch.
@@ -49,6 +75,11 @@ public sealed class LegacyRefreshScheduler
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(waitForPlayCompletionAsync);
+ if (HasReachedMaximum)
+ {
+ throw new InvalidOperationException(
+ "The configured legacy refresh limit has already been reached.");
+ }
if (!await waitForPlayCompletionAsync(cancellationToken))
{
@@ -66,11 +97,78 @@ public sealed class LegacyRefreshScheduler
/// Executes at most one refresh at a time, even if the scheduler is used
/// incorrectly by more than one caller.
///
- public async Task ExecuteRefreshAsync(
+ internal async Task ExecuteRefreshAsync(
Func> refreshAsync,
CancellationToken cancellationToken)
+ {
+ if (_maximumRefreshCount is not null)
+ {
+ throw new InvalidOperationException(
+ "A bounded legacy refresh must use the tracked execution path.");
+ }
+
+ return await ExecuteRefreshCoreAsync(
+ refreshAsync,
+ successPredicate: null,
+ cancellationToken);
+ }
+
+ ///
+ /// Executes one refresh and records a successful dispatch before releasing the
+ /// in-flight latch. This closes the dispatch/count gap at a configured ceiling.
+ ///
+ public async Task ExecuteRefreshAndTrackSuccessAsync(
+ Func> refreshAsync,
+ Func successPredicate,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(successPredicate);
+ return await ExecuteRefreshCoreAsync(
+ refreshAsync,
+ successPredicate,
+ cancellationToken);
+ }
+
+ ///
+ /// Confirms the final Play callback after the dispatch ceiling is reached.
+ /// A false callback result leaves the ceiling unconfirmed for fail-closed status.
+ ///
+ public async Task DrainMaximumCallbackAsync(
+ Func> waitForPlayCompletionAsync,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(waitForPlayCompletionAsync);
+ if (!HasReachedMaximum)
+ {
+ throw new InvalidOperationException(
+ "The configured legacy refresh limit has not been reached.");
+ }
+
+ if (IsMaximumCallbackDrained)
+ {
+ return true;
+ }
+
+ if (!await waitForPlayCompletionAsync(cancellationToken))
+ {
+ return false;
+ }
+
+ Volatile.Write(ref _maximumCallbackDrained, 1);
+ return true;
+ }
+
+ private async Task ExecuteRefreshCoreAsync(
+ Func> refreshAsync,
+ Func? successPredicate,
+ CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(refreshAsync);
+ if (HasReachedMaximum)
+ {
+ throw new InvalidOperationException(
+ "The configured legacy refresh limit has already been reached.");
+ }
if (Interlocked.CompareExchange(ref _refreshInFlight, 1, 0) != 0)
{
@@ -79,7 +177,13 @@ public sealed class LegacyRefreshScheduler
try
{
- return await refreshAsync(cancellationToken);
+ var result = await refreshAsync(cancellationToken);
+ if (successPredicate?.Invoke(result) == true)
+ {
+ TrackRefreshSucceeded();
+ }
+
+ return result;
}
finally
{
@@ -87,7 +191,28 @@ public sealed class LegacyRefreshScheduler
}
}
- public void MarkRefreshSucceeded() => _nextDelay = _recurringDelay;
+ internal void MarkRefreshSucceeded()
+ {
+ if (_maximumRefreshCount is not null)
+ {
+ throw new InvalidOperationException(
+ "A bounded legacy refresh is tracked by its execution path.");
+ }
+
+ TrackRefreshSucceeded();
+ }
+
+ private void TrackRefreshSucceeded()
+ {
+ if (HasReachedMaximum)
+ {
+ throw new InvalidOperationException(
+ "The configured legacy refresh limit has already been reached.");
+ }
+
+ Interlocked.Increment(ref _completedRefreshCount);
+ _nextDelay = _recurringDelay;
+ }
private static void ValidateDelay(TimeSpan delay, string parameterName)
{
diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptions.cs b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptions.cs
index a13b6c3..303d130 100644
--- a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptions.cs
+++ b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptions.cs
@@ -75,4 +75,13 @@ public sealed class PlayoutOptions
public int MaximumReconnectAttempts { get; set; } = 3;
public bool ReconnectEnabled { get; set; } = true;
+
+ ///
+ /// Optional trusted local safety ceiling for timer-driven same-scene refreshes
+ /// started by one TAKE IN. Null preserves the legacy continuous-refresh behavior;
+ /// zero disables automatic refresh; a positive value stops after that many
+ /// successful refresh dispatches and drains the final play callback before
+ /// reporting that the limit was reached. Web content cannot change this value.
+ ///
+ public int? MaximumAutomaticRefreshesPerTakeIn { get; set; }
}
diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs
index 1ebcf5f..c405861 100644
--- a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs
+++ b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs
@@ -98,6 +98,14 @@ internal sealed record ValidatedPlayoutOptions(
0,
1_000,
nameof(options.MaximumReconnectAttempts));
+ if (options.MaximumAutomaticRefreshesPerTakeIn is { } maximumAutomaticRefreshes)
+ {
+ RequireRange(
+ maximumAutomaticRefreshes,
+ 0,
+ 1_000_000,
+ nameof(options.MaximumAutomaticRefreshesPerTakeIn));
+ }
Regex? titleRegex = null;
var allowlist = new HashSet(StringComparer.OrdinalIgnoreCase);
diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyRefreshSchedulerTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyRefreshSchedulerTests.cs
index 50d8355..c0ebafe 100644
--- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyRefreshSchedulerTests.cs
+++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyRefreshSchedulerTests.cs
@@ -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(() =>
+ scheduler.ExecuteRefreshAsync(
+ _ =>
+ {
+ untrackedCalls++;
+ return Task.FromResult(true);
+ },
+ CancellationToken.None));
+ Assert.Equal(0, untrackedCalls);
+ Assert.Throws(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();
+ 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(() =>
+ scheduler.WaitForNextRefreshAsync(
+ _ => Task.FromResult(true),
+ null,
+ CancellationToken.None));
+ var unexpectedRefreshCalls = 0;
+ await Assert.ThrowsAsync(() =>
+ scheduler.ExecuteRefreshAsync(
+ _ =>
+ {
+ unexpectedRefreshCalls++;
+ return Task.FromResult(true);
+ },
+ CancellationToken.None));
+ Assert.Equal(0, unexpectedRefreshCalls);
+ Assert.Equal(1, delays.CallCount);
+ Assert.Throws(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(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(() =>
+ 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 RunOneRefreshAsync(
LegacyRefreshScheduler scheduler,
diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs
index 26f351d..946dd4d 100644
--- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs
+++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs
@@ -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(
+ () => 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")]
diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs
index 0f37cf8..f700661 100644
--- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs
+++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs
@@ -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(
+ () => 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()
diff --git a/tests/Web/playout-safety.test.cjs b/tests/Web/playout-safety.test.cjs
index e078cac..905b5d8 100644
--- a/tests/Web/playout-safety.test.cjs
+++ b/tests/Web/playout-safety.test.cjs
@@ -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);