feat: harden isolated Tornado test workflow
This commit is contained in:
@@ -8,9 +8,6 @@ namespace MBN_STOCK_WEBVIEW;
|
||||
public sealed partial class MainWindow
|
||||
{
|
||||
private const int MaximumPlayoutRequestIdLength = 128;
|
||||
private const int MaximumPlayoutCodeLength = 64;
|
||||
private const int MaximumPlayoutTitleLength = 200;
|
||||
private const int MaximumPlayoutDetailLength = 500;
|
||||
|
||||
private readonly SemaphoreSlim _playoutCommandGate = new(1, 1);
|
||||
private IPlayoutEngine? _playoutEngine;
|
||||
@@ -95,14 +92,15 @@ public sealed partial class MainWindow
|
||||
|
||||
private async Task HandlePlayoutCommandAsync(JsonElement payload)
|
||||
{
|
||||
var parsed = TryParsePlayoutCommand(payload, out var request, out var validationError);
|
||||
if (!parsed)
|
||||
var parsed = PlayoutBridgeProtocol.ParseCommand(payload);
|
||||
var request = parsed.Request;
|
||||
if (!parsed.IsValid)
|
||||
{
|
||||
PostPlayoutCommandError(
|
||||
request.RequestId,
|
||||
request.Command,
|
||||
"INVALID_REQUEST",
|
||||
validationError,
|
||||
parsed.Error,
|
||||
retryable: false,
|
||||
outcomeUnknown: false);
|
||||
return;
|
||||
@@ -147,7 +145,7 @@ public sealed partial class MainWindow
|
||||
ToWireValue(result.Code),
|
||||
result.Message,
|
||||
IsRetryable(result.Code),
|
||||
result.Code == PlayoutResultCode.OutcomeUnknown);
|
||||
result.Code is PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -180,19 +178,23 @@ public sealed partial class MainWindow
|
||||
request.Command,
|
||||
"PLAYOUT_FAILED",
|
||||
"송출 엔진 명령을 완료하지 못했습니다.",
|
||||
retryable: true,
|
||||
retryable: false,
|
||||
outcomeUnknown: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _playoutCommandInFlight, 0);
|
||||
_playoutCommandGate.Release();
|
||||
// A browser-side timeout may have discarded the correlated result while the
|
||||
// native operation was still completing. Always publish the authoritative
|
||||
// engine state after the command leaves the serialization gate.
|
||||
QueuePlayoutStatus();
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<PlayoutResult> ExecutePlayoutCommandAsync(
|
||||
IPlayoutEngine engine,
|
||||
ValidatedPlayoutCommand request,
|
||||
PlayoutBridgeCommand request,
|
||||
CancellationToken cancellationToken) => request.Command switch
|
||||
{
|
||||
"prepare" => engine.PrepareAsync(request.Cue!, cancellationToken),
|
||||
@@ -216,9 +218,13 @@ public sealed partial class MainWindow
|
||||
{
|
||||
mode = "disabled",
|
||||
state = "IDLE",
|
||||
connectionState = "unavailable",
|
||||
processDetected = false,
|
||||
connected = false,
|
||||
commandAvailable = false,
|
||||
liveTakeInAllowed = false,
|
||||
outcomeUnknown = false,
|
||||
operationTimeoutMilliseconds = 5000,
|
||||
message = _playoutInitializationError ?? "Tornado 송출 어댑터가 비활성화되어 있습니다.",
|
||||
preparedCode = (string?)null,
|
||||
onAirCode = (string?)null,
|
||||
@@ -232,9 +238,13 @@ public sealed partial class MainWindow
|
||||
requestId,
|
||||
mode = "disabled",
|
||||
state = "IDLE",
|
||||
connectionState = "unavailable",
|
||||
processDetected = false,
|
||||
connected = false,
|
||||
commandAvailable = false,
|
||||
liveTakeInAllowed = false,
|
||||
outcomeUnknown = false,
|
||||
operationTimeoutMilliseconds = 5000,
|
||||
message = _playoutInitializationError ?? "Tornado 송출 어댑터가 비활성화되어 있습니다.",
|
||||
preparedCode = (string?)null,
|
||||
onAirCode = (string?)null,
|
||||
@@ -255,9 +265,13 @@ public sealed partial class MainWindow
|
||||
requestId,
|
||||
mode = ToWireValue(status.Mode),
|
||||
state = ToPlayoutPhase(status),
|
||||
connectionState = ToWireValue(status.State),
|
||||
processDetected = status.IsProcessRunning,
|
||||
connected = status.IsConnected,
|
||||
commandAvailable = status.IsCommandAvailable,
|
||||
liveTakeInAllowed = status.LiveTakeInAllowed,
|
||||
outcomeUnknown = status.State == PlayoutConnectionState.OutcomeUnknown,
|
||||
operationTimeoutMilliseconds = status.OperationTimeoutMilliseconds,
|
||||
message = status.Message,
|
||||
preparedCode = status.PreparedSceneName,
|
||||
onAirCode = status.OnAirSceneName,
|
||||
@@ -270,9 +284,13 @@ public sealed partial class MainWindow
|
||||
{
|
||||
mode = ToWireValue(status.Mode),
|
||||
state = ToPlayoutPhase(status),
|
||||
connectionState = ToWireValue(status.State),
|
||||
processDetected = status.IsProcessRunning,
|
||||
connected = status.IsConnected,
|
||||
commandAvailable = status.IsCommandAvailable,
|
||||
liveTakeInAllowed = status.LiveTakeInAllowed,
|
||||
outcomeUnknown = status.State == PlayoutConnectionState.OutcomeUnknown,
|
||||
operationTimeoutMilliseconds = status.OperationTimeoutMilliseconds,
|
||||
message = status.Message,
|
||||
preparedCode = status.PreparedSceneName,
|
||||
onAirCode = status.OnAirSceneName,
|
||||
@@ -298,91 +316,6 @@ public sealed partial class MainWindow
|
||||
});
|
||||
}
|
||||
|
||||
private static bool TryParsePlayoutCommand(
|
||||
JsonElement payload,
|
||||
out ValidatedPlayoutCommand request,
|
||||
out string error)
|
||||
{
|
||||
var requestId = payload.ValueKind == JsonValueKind.Object
|
||||
? GetString(payload, "requestId") ?? string.Empty
|
||||
: string.Empty;
|
||||
var command = payload.ValueKind == JsonValueKind.Object
|
||||
? GetString(payload, "command") ?? string.Empty
|
||||
: string.Empty;
|
||||
request = new ValidatedPlayoutCommand(requestId, command, null);
|
||||
|
||||
if (payload.ValueKind != JsonValueKind.Object ||
|
||||
!HasOnlyProperties(payload, "requestId", "command", "cue"))
|
||||
{
|
||||
error = "송출 요청에 허용되지 않은 필드가 있습니다.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId))
|
||||
{
|
||||
error = "송출 요청 식별자가 올바르지 않습니다.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (command is not ("prepare" or "take-in" or "next" or "take-out"))
|
||||
{
|
||||
error = "지원하지 않는 송출 명령입니다.";
|
||||
request = new ValidatedPlayoutCommand(requestId, command, null);
|
||||
return false;
|
||||
}
|
||||
|
||||
request = new ValidatedPlayoutCommand(requestId, command, null);
|
||||
var cueRequired = command is not "take-out";
|
||||
if (!payload.TryGetProperty("cue", out var cueElement))
|
||||
{
|
||||
if (!cueRequired)
|
||||
{
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = "이 송출 명령에는 그래픽 cue가 필요합니다.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryParsePlayoutCue(cueElement, out var cue, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
request = new ValidatedPlayoutCommand(requestId, command, cue);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParsePlayoutCue(
|
||||
JsonElement payload,
|
||||
out PlayoutCue? cue,
|
||||
out string error)
|
||||
{
|
||||
cue = null;
|
||||
if (payload.ValueKind != JsonValueKind.Object ||
|
||||
!HasOnlyProperties(payload, "code", "title", "detail") ||
|
||||
!TryGetSafeToken(payload, "code", MaximumPlayoutCodeLength, out var code) ||
|
||||
!TryGetBoundedText(payload, "title", MaximumPlayoutTitleLength, allowEmpty: false, out _) ||
|
||||
!TryGetBoundedText(payload, "detail", MaximumPlayoutDetailLength, allowEmpty: true, out _))
|
||||
{
|
||||
error = "그래픽 cue 형식이 올바르지 않습니다.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var sceneFile = $"{code}.t2s";
|
||||
if (!string.Equals(Path.GetFileName(sceneFile), sceneFile, StringComparison.Ordinal) ||
|
||||
sceneFile.Contains("..", StringComparison.Ordinal))
|
||||
{
|
||||
error = "그래픽 cue 경로가 올바르지 않습니다.";
|
||||
return false;
|
||||
}
|
||||
|
||||
cue = new PlayoutCue(sceneFile, code);
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryGetSafeToken(
|
||||
JsonElement payload,
|
||||
string propertyName,
|
||||
@@ -394,19 +327,6 @@ public sealed partial class MainWindow
|
||||
value.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-');
|
||||
}
|
||||
|
||||
private static bool TryGetBoundedText(
|
||||
JsonElement payload,
|
||||
string propertyName,
|
||||
int maximumLength,
|
||||
bool allowEmpty,
|
||||
out string value)
|
||||
{
|
||||
value = GetString(payload, propertyName) ?? string.Empty;
|
||||
return value.Length <= maximumLength &&
|
||||
(allowEmpty || value.Length > 0) &&
|
||||
!value.Any(char.IsControl);
|
||||
}
|
||||
|
||||
private static bool HasOnlyProperties(JsonElement payload, params string[] allowed)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
@@ -441,6 +361,21 @@ public sealed partial class MainWindow
|
||||
_ => "disabled"
|
||||
};
|
||||
|
||||
private static string ToWireValue(PlayoutConnectionState state) => state switch
|
||||
{
|
||||
PlayoutConnectionState.Disabled => "disabled",
|
||||
PlayoutConnectionState.DryRunReady => "dry-run-ready",
|
||||
PlayoutConnectionState.Disconnected => "disconnected",
|
||||
PlayoutConnectionState.Connecting => "connecting",
|
||||
PlayoutConnectionState.Connected => "connected",
|
||||
PlayoutConnectionState.Reconnecting => "reconnecting",
|
||||
PlayoutConnectionState.Disconnecting => "disconnecting",
|
||||
PlayoutConnectionState.Faulted => "faulted",
|
||||
PlayoutConnectionState.OutcomeUnknown => "outcome-unknown",
|
||||
PlayoutConnectionState.Disposed => "disposed",
|
||||
_ => "unavailable"
|
||||
};
|
||||
|
||||
private static string ToWireValue(PlayoutResultCode code) => code switch
|
||||
{
|
||||
PlayoutResultCode.Rejected => "REJECTED",
|
||||
@@ -453,10 +388,8 @@ public sealed partial class MainWindow
|
||||
|
||||
private static bool IsRetryable(PlayoutResultCode code) => code is
|
||||
PlayoutResultCode.Cancelled or
|
||||
PlayoutResultCode.TimedOut or
|
||||
PlayoutResultCode.Unavailable or
|
||||
PlayoutResultCode.Failed or
|
||||
PlayoutResultCode.OutcomeUnknown;
|
||||
PlayoutResultCode.Failed;
|
||||
|
||||
private void ShutdownPlayoutRuntime()
|
||||
{
|
||||
@@ -495,9 +428,4 @@ public sealed partial class MainWindow
|
||||
// Disposal is best-effort and bounded during window shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record ValidatedPlayoutCommand(
|
||||
string RequestId,
|
||||
string Command,
|
||||
PlayoutCue? Cue);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user