fix: preserve operator window between scene refreshes
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user