feat: harden isolated Tornado test workflow

This commit is contained in:
2026-07-10 11:27:40 +09:00
parent 5a8c7028dc
commit fc932b27f6
36 changed files with 3919 additions and 226 deletions

View File

@@ -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);
}