fix: preserve operator window between scene refreshes

This commit is contained in:
2026-07-11 14:55:36 +09:00
parent 673d40b09f
commit fa0eae7b61
6 changed files with 721 additions and 81 deletions

View File

@@ -20,10 +20,9 @@ public sealed partial class MainWindow
private int _playoutCommandInFlight;
private int _playoutShutdownStarted;
private int _browserCorrelationQuarantined;
private CancellationTokenSource? _legacyRefreshCancellation;
private Task? _legacyRefreshTask;
private readonly object _legacyRefreshStatusGate = new();
private LegacyRefreshRuntimeStatus _legacyRefreshStatus = LegacyRefreshRuntimeStatus.Empty;
private readonly LegacyRefreshEpoch<LegacyRefreshRuntimeStatus> _legacyRefreshEpoch =
new(LegacyRefreshRuntimeStatus.Empty);
private void InitializePlayoutRuntime()
{
@@ -174,10 +173,6 @@ public sealed partial class MainWindow
try
{
Interlocked.Exchange(ref _playoutCommandInFlight, 1);
CancelActiveMarketDataRequest();
CancelActiveDatabaseHealthCheck();
await _databaseActivityGate.WaitAsync(_lifetimeCancellation.Token);
databaseActivityEntered = true;
if (IsBrowserCorrelationQuarantined())
{
PostPlayoutCommandError(
@@ -208,6 +203,37 @@ public sealed partial class MainWindow
// MainForm stops timer1 before every operator command. TAKE IN and a
// successful playlist NEXT start it again; Page NEXT intentionally does not.
StopLegacyRefreshLoop();
// Do not wait or enter the workflow while a prior Play callback is
// pending. NEXT fails closed immediately so the browser request and both
// gates are released, leaving an emergency TAKE OUT actionable.
if (request.Command == "next" && engine.Status.IsPlayCompletionPending)
{
PostPlayoutCommandError(
request.RequestId,
request.Command,
"PLAY_CALLBACK_PENDING",
"Tornado play completion is still pending. Wait for the status update or use TAKE OUT.",
retryable: true,
outcomeUnknown: false);
return;
}
CancelActiveMarketDataRequest();
CancelActiveDatabaseHealthCheck();
await _databaseActivityGate.WaitAsync(_lifetimeCancellation.Token);
databaseActivityEntered = true;
if (IsBrowserCorrelationQuarantined())
{
PostPlayoutCommandError(
request.RequestId,
request.Command,
"OUTCOME_UNKNOWN",
BrowserCorrelationQuarantineMessage,
retryable: false,
outcomeUnknown: true);
return;
}
var result = await ExecutePlayoutCommandAsync(
workflow,
request,
@@ -730,76 +756,97 @@ public sealed partial class MainWindow
var cancellation = CancellationTokenSource.CreateLinkedTokenSource(
_lifetimeCancellation.Token);
_legacyRefreshCancellation = cancellation;
SetLegacyRefreshStatus(new LegacyRefreshRuntimeStatus(
true,
DateTimeOffset.UtcNow.Add(interval),
GetLegacyRefreshStatus().LastSuccessAtUtc,
false,
null,
null));
var scheduler = new LegacyRefreshScheduler(
interval,
LegacySceneRefreshPolicy.RecurringInterval);
_legacyRefreshEpoch.Replace(
cancellation,
status => new LegacyRefreshRuntimeStatus(
true,
null,
status.LastSuccessAtUtc,
false,
null,
null));
_legacyRefreshTask = RunLegacyRefreshLoopAsync(
workflow,
interval,
scheduler,
cancellation);
}
private void StopLegacyRefreshLoop()
{
var cancellation = Interlocked.Exchange(
ref _legacyRefreshCancellation,
null);
if (cancellation is not null)
_legacyRefreshEpoch.Stop(status => status with
{
cancellation.Cancel();
}
var status = GetLegacyRefreshStatus();
if (status.IsActive)
{
SetLegacyRefreshStatus(status with { IsActive = false, NextAtUtc = null });
}
IsActive = false,
NextAtUtc = null
});
}
private async Task RunLegacyRefreshLoopAsync(
LegacyPlayoutWorkflow workflow,
TimeSpan interval,
LegacyRefreshScheduler scheduler,
CancellationTokenSource cancellation)
{
var token = cancellation.Token;
var nextDelay = interval;
try
{
while (true)
{
await Task.Delay(nextDelay, token);
var engine = _playoutEngine;
if (engine is null ||
!ReferenceEquals(_playoutWorkflow, workflow) ||
!ReferenceEquals(_legacyRefreshCancellation, cancellation) ||
!_legacyRefreshEpoch.IsCurrent(cancellation) ||
Volatile.Read(ref _playoutShutdownStarted) != 0)
{
return;
}
// Play returns before Tornado necessarily delivers OnScenePlayed. Waiting
// outside the app command gate keeps emergency TAKE OUT available and does
// not dispatch, repeat, or probe any SDK mutation.
if (!await WaitForPlayCompletionAsync(engine, token))
// 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
// cooldown. This intentionally lengthens the effective cadence while
// keeping the command gate free for a real operator quiescent window.
if (!await scheduler.WaitForNextRefreshAsync(
cancellationToken => WaitForPlayCompletionAsync(
engine,
cancellationToken),
scheduledDelay =>
{
if (_legacyRefreshEpoch.TryUpdate(
cancellation,
status => status.IsActive
? status with
{
NextAtUtc = DateTimeOffset.UtcNow.Add(scheduledDelay)
}
: status))
{
QueuePlayoutStatus();
}
},
token))
{
SetLegacyRefreshFault(
TrySetLegacyRefreshFault(
cancellation,
"PLAY_CALLBACK_TIMEOUT",
"Tornado 재생 완료 이벤트를 제한 시간 안에 확인하지 못해 자동 장면 갱신을 중단했습니다. TAKE OUT 후 실제 화면을 확인하세요.");
QueuePlayoutStatus();
return;
}
if (!ReferenceEquals(_playoutWorkflow, workflow) ||
!_legacyRefreshEpoch.IsCurrent(cancellation) ||
Volatile.Read(ref _playoutShutdownStarted) != 0)
{
return;
}
await _playoutCommandGate.WaitAsync(token);
var databaseActivityEntered = false;
try
{
if (!ReferenceEquals(_legacyRefreshCancellation, cancellation) ||
if (!_legacyRefreshEpoch.IsCurrent(cancellation) ||
Volatile.Read(ref _playoutShutdownStarted) != 0)
{
return;
@@ -810,27 +857,36 @@ public sealed partial class MainWindow
CancelActiveDatabaseHealthCheck();
await _databaseActivityGate.WaitAsync(token);
databaseActivityEntered = true;
var result = await workflow.RefreshOnAirAsync(token);
var result = await scheduler.ExecuteRefreshAsync(
workflow.RefreshOnAirAsync,
token);
// A failed, timed-out, or unknown update never repeats. The operator
// must reconcile native/PGM state before starting another command.
if (!result.IsSuccess)
{
SetLegacyRefreshFault(
TrySetLegacyRefreshFault(
cancellation,
ToWireValue(result.Code),
"자동 장면 갱신이 중단되었습니다. TAKE OUT 후 실제 화면과 데이터를 확인하세요.");
return;
}
// MainForm changes timer1.Interval to 3000 after the first
// successful same-scene refresh.
nextDelay = LegacySceneRefreshPolicy.RecurringInterval;
SetLegacyRefreshStatus(new LegacyRefreshRuntimeStatus(
true,
DateTimeOffset.UtcNow.Add(nextDelay),
DateTimeOffset.UtcNow,
false,
null,
null));
// 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(
true,
null,
DateTimeOffset.UtcNow,
false,
null,
null)))
{
return;
}
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
@@ -838,21 +894,24 @@ public sealed partial class MainWindow
}
catch (DatabaseOperationException)
{
SetLegacyRefreshFault(
TrySetLegacyRefreshFault(
cancellation,
"DATABASE_UNAVAILABLE",
"자동 장면 갱신 중 데이터베이스 조회가 실패했습니다. TAKE OUT 후 확인하세요.");
return;
}
catch (LegacySceneDataException)
{
SetLegacyRefreshFault(
TrySetLegacyRefreshFault(
cancellation,
"SCENE_DATA_INVALID",
"자동 장면 갱신 데이터가 유효하지 않습니다. TAKE OUT 후 확인하세요.");
return;
}
catch
{
SetLegacyRefreshFault(
TrySetLegacyRefreshFault(
cancellation,
"REFRESH_FAILED",
"자동 장면 갱신을 완료하지 못했습니다. TAKE OUT 후 확인하세요.");
return;
@@ -875,11 +934,18 @@ public sealed partial class MainWindow
}
finally
{
Interlocked.CompareExchange(
ref _legacyRefreshCancellation,
null,
cancellation);
var completedCurrentEpoch = _legacyRefreshEpoch.TryComplete(
cancellation,
status => status with
{
IsActive = false,
NextAtUtc = null
});
cancellation.Dispose();
if (completedCurrentEpoch)
{
QueuePlayoutStatus();
}
}
}
@@ -941,33 +1007,25 @@ public sealed partial class MainWindow
return true;
}
private LegacyRefreshRuntimeStatus GetLegacyRefreshStatus()
{
lock (_legacyRefreshStatusGate)
{
return _legacyRefreshStatus;
}
}
private LegacyRefreshRuntimeStatus GetLegacyRefreshStatus() =>
_legacyRefreshEpoch.ReadState();
private void SetLegacyRefreshStatus(LegacyRefreshRuntimeStatus status)
{
lock (_legacyRefreshStatusGate)
{
_legacyRefreshStatus = status;
}
}
private void SetLegacyRefreshFault(string code, string message) =>
SetLegacyRefreshStatus(new LegacyRefreshRuntimeStatus(
false,
null,
GetLegacyRefreshStatus().LastSuccessAtUtc,
true,
code,
message));
private bool TrySetLegacyRefreshFault(
CancellationTokenSource cancellation,
string code,
string message) =>
_legacyRefreshEpoch.TryUpdate(
cancellation,
status => new LegacyRefreshRuntimeStatus(
false,
null,
status.LastSuccessAtUtc,
true,
code,
message));
private void ResetLegacyRefreshStatus() =>
SetLegacyRefreshStatus(LegacyRefreshRuntimeStatus.Empty);
_legacyRefreshEpoch.Stop(_ => LegacyRefreshRuntimeStatus.Empty);
private void ShutdownPlayoutRuntime()
{