feat: add safe Tornado K3D playout adapter

This commit is contained in:
2026-07-10 06:41:48 +09:00
parent 39c4504b87
commit 5a8c7028dc
43 changed files with 7341 additions and 92 deletions

View File

@@ -0,0 +1,422 @@
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
namespace MBN_STOCK_WEBVIEW.Playout.Runtime;
internal interface IStaDispatcher : IAsyncDisposable
{
bool IsQuarantined { get; }
int? ManagedThreadId { get; }
Task<T> InvokeAsync<T>(
Func<T> callback,
TimeSpan timeout,
CancellationToken cancellationToken,
Action<T>? abandonedResultCleanup = null,
Action? abandonedFailureCleanup = null);
}
internal interface IStaDispatcherFactory
{
IStaDispatcher Create(int capacity);
}
internal sealed class StaDispatcherFactory : IStaDispatcherFactory
{
public IStaDispatcher Create(int capacity) => new StaDispatcher(capacity);
}
internal sealed class StaDispatcher : IStaDispatcher
{
private const uint QsAllInput = 0x04ff;
private const uint MwmoInputAvailable = 0x0004;
private const uint PmRemove = 0x0001;
private const uint WaitObject0 = 0;
private const uint WaitFailed = 0xffffffff;
private readonly ConcurrentQueue<StaWorkItem> _queue = new();
private readonly SemaphoreSlim _slots;
private readonly AutoResetEvent _workAvailable = new(false);
private readonly Thread _thread;
private int _state;
private int _managedThreadId;
public StaDispatcher(int capacity)
{
if (capacity < 1)
{
throw new ArgumentOutOfRangeException(nameof(capacity));
}
_slots = new SemaphoreSlim(capacity, capacity);
_thread = new Thread(ThreadMain)
{
IsBackground = true,
Name = "MBN K3D STA Dispatcher"
};
_thread.SetApartmentState(ApartmentState.STA);
_thread.Start();
}
public bool IsQuarantined => Volatile.Read(ref _state) == 1;
public int? ManagedThreadId
{
get
{
var value = Volatile.Read(ref _managedThreadId);
return value == 0 ? null : value;
}
}
public async Task<T> InvokeAsync<T>(
Func<T> callback,
TimeSpan timeout,
CancellationToken cancellationToken,
Action<T>? abandonedResultCleanup = null,
Action? abandonedFailureCleanup = null)
{
ArgumentNullException.ThrowIfNull(callback);
if (timeout <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(timeout));
}
ThrowIfUnavailable();
await _slots.WaitAsync(cancellationToken).ConfigureAwait(false);
var item = new StaWorkItem<T>(
callback,
abandonedResultCleanup,
abandonedFailureCleanup);
var queued = false;
try
{
ThrowIfUnavailable();
using var registration = cancellationToken.Register(
static state => ((StaWorkItem)state!).CancelBeforeExecution(),
item);
_queue.Enqueue(item);
queued = true;
_workAvailable.Set();
var completion = item.Completion;
var timeoutTask = Task.Delay(timeout);
var completed = await Task.WhenAny(completion, timeoutTask).ConfigureAwait(false);
if (completed == completion || completion.IsCompleted)
{
return await completion.ConfigureAwait(false);
}
if (item.TryTimeout())
{
Quarantine();
}
return await completion.ConfigureAwait(false);
}
catch
{
if (!queued)
{
_slots.Release();
}
throw;
}
}
public ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _state, 2) == 2)
{
return ValueTask.CompletedTask;
}
FailQueuedItems(new ObjectDisposedException(nameof(StaDispatcher)));
_workAvailable.Set();
if (Thread.CurrentThread != _thread)
{
_thread.Join(TimeSpan.FromSeconds(2));
}
_slots.Dispose();
_workAvailable.Dispose();
return ValueTask.CompletedTask;
}
private void ThreadMain()
{
Volatile.Write(ref _managedThreadId, Environment.CurrentManagedThreadId);
var waitHandles = new[] { _workAvailable.SafeWaitHandle.DangerousGetHandle() };
while (Volatile.Read(ref _state) != 2)
{
PumpMessages();
if (_queue.TryDequeue(out var item))
{
_slots.Release();
if (Volatile.Read(ref _state) == 0)
{
item.Execute();
}
else
{
item.Fail(new StaDispatcherQuarantinedException());
}
continue;
}
var waitResult = MsgWaitForMultipleObjectsEx(
1,
waitHandles,
250,
QsAllInput,
MwmoInputAvailable);
if (waitResult == WaitFailed)
{
Quarantine();
}
else if (waitResult == WaitObject0 + 1)
{
PumpMessages();
}
}
PumpMessages();
}
private void Quarantine()
{
if (Interlocked.CompareExchange(ref _state, 1, 0) != 0)
{
return;
}
FailQueuedItems(new StaDispatcherQuarantinedException());
_workAvailable.Set();
}
private void FailQueuedItems(Exception exception)
{
while (_queue.TryDequeue(out var queued))
{
_slots.Release();
queued.Fail(exception);
}
}
private void ThrowIfUnavailable()
{
var state = Volatile.Read(ref _state);
if (state == 1)
{
throw new StaDispatcherQuarantinedException();
}
ObjectDisposedException.ThrowIf(state == 2, this);
}
private static void PumpMessages()
{
while (PeekMessage(out var message, IntPtr.Zero, 0, 0, PmRemove))
{
TranslateMessage(in message);
DispatchMessage(in message);
}
}
[DllImport("user32.dll", SetLastError = true)]
private static extern uint MsgWaitForMultipleObjectsEx(
uint count,
[In] IntPtr[] handles,
uint milliseconds,
uint wakeMask,
uint flags);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PeekMessage(
out NativeMessage message,
IntPtr window,
uint filterMinimum,
uint filterMaximum,
uint removeMessage);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool TranslateMessage(in NativeMessage message);
[DllImport("user32.dll")]
private static extern IntPtr DispatchMessage(in NativeMessage message);
[StructLayout(LayoutKind.Sequential)]
private struct NativeMessage
{
public IntPtr Window;
public uint Message;
public UIntPtr WParam;
public IntPtr LParam;
public uint Time;
public NativePoint Point;
public uint Private;
}
[StructLayout(LayoutKind.Sequential)]
private struct NativePoint
{
public int X;
public int Y;
}
private abstract class StaWorkItem
{
// 0 queued, 1 running, 2 completed/cancelled/timed out
private int _executionState;
public void Execute()
{
if (Interlocked.CompareExchange(ref _executionState, 1, 0) != 0)
{
return;
}
ExecuteCore();
}
public void CancelBeforeExecution()
{
if (Interlocked.CompareExchange(ref _executionState, 2, 0) == 0)
{
CancelCore();
}
}
public bool TryTimeout()
{
while (true)
{
var state = Volatile.Read(ref _executionState);
if (state == 2)
{
return false;
}
if (Interlocked.CompareExchange(ref _executionState, 2, state) == state)
{
TimeoutCore();
return true;
}
}
}
public void Fail(Exception exception)
{
if (Interlocked.Exchange(ref _executionState, 2) != 2)
{
FailCore(exception);
}
}
protected bool TryCompleteExecution() =>
Interlocked.CompareExchange(ref _executionState, 2, 1) == 1;
protected abstract void ExecuteCore();
protected abstract void CancelCore();
protected abstract void TimeoutCore();
protected abstract void FailCore(Exception exception);
}
private sealed class StaWorkItem<T> : StaWorkItem
{
private readonly Func<T> _callback;
private readonly Action<T>? _abandonedResultCleanup;
private readonly Action? _abandonedFailureCleanup;
private readonly TaskCompletionSource<T> _completion =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public StaWorkItem(
Func<T> callback,
Action<T>? abandonedResultCleanup,
Action? abandonedFailureCleanup)
{
_callback = callback;
_abandonedResultCleanup = abandonedResultCleanup;
_abandonedFailureCleanup = abandonedFailureCleanup;
}
public Task<T> Completion => _completion.Task;
protected override void ExecuteCore()
{
try
{
var result = _callback();
if (TryCompleteExecution())
{
_completion.TrySetResult(result);
}
else
{
try
{
_abandonedResultCleanup?.Invoke(result);
}
catch
{
// Late cleanup is best effort and must not terminate the STA pump.
}
}
}
catch (Exception exception)
{
if (TryCompleteExecution())
{
_completion.TrySetException(exception);
}
else
{
try
{
_abandonedFailureCleanup?.Invoke();
}
catch
{
// Late cleanup is best effort and must not terminate the STA pump.
}
}
}
}
protected override void CancelCore() =>
_completion.TrySetCanceled();
protected override void TimeoutCore() =>
_completion.TrySetException(new StaOperationTimedOutException());
protected override void FailCore(Exception exception) =>
_completion.TrySetException(exception);
}
}
internal sealed class StaOperationTimedOutException : TimeoutException
{
public StaOperationTimedOutException()
: base("The STA operation timed out and its outcome is unknown.")
{
}
}
internal sealed class StaDispatcherQuarantinedException : InvalidOperationException
{
public StaDispatcherQuarantinedException()
: base("The STA dispatcher is quarantined.")
{
}
}

View File

@@ -0,0 +1,125 @@
using System.Diagnostics;
using System.ComponentModel;
using System.Text.RegularExpressions;
namespace MBN_STOCK_WEBVIEW.Playout.Runtime;
internal sealed record TornadoProcessSnapshot(
int TotalProcessCount,
int EligibleProcessCount,
int ProgramProcessCount)
{
public bool AnyTornadoProcess => TotalProcessCount > 0;
public bool HasSingleEligibleProcess => EligibleProcessCount == 1;
public bool UnsafeProgramWindowMatched => ProgramProcessCount > 0;
}
internal interface ITornadoProcessProbe
{
TornadoProcessSnapshot Capture(Regex? testWindowPattern);
}
internal sealed class TornadoProcessProbe : ITornadoProcessProbe
{
internal const string ProcessNamePrefix = "Tornado2";
public TornadoProcessSnapshot Capture(Regex? testWindowPattern)
{
var totalCount = 0;
var eligibleCount = 0;
var programCount = 0;
Process[] processes;
try
{
processes = Process.GetProcesses();
}
catch (Exception exception) when (IsProcessInspectionException(exception))
{
return new TornadoProcessSnapshot(0, 0, 0);
}
foreach (var process in processes)
{
try
{
string processName;
try
{
processName = process.ProcessName;
}
catch (Exception exception) when (IsProcessInspectionException(exception))
{
continue;
}
if (!processName.StartsWith(ProcessNamePrefix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
totalCount++;
if (testWindowPattern is null)
{
eligibleCount++;
continue;
}
string title;
try
{
title = process.MainWindowTitle;
}
catch (Exception exception) when (IsProcessInspectionException(exception))
{
continue;
}
if (IsProgramTitle(title))
{
programCount++;
continue;
}
bool titleMatches;
try
{
titleMatches = testWindowPattern.IsMatch(title);
}
catch (RegexMatchTimeoutException)
{
titleMatches = false;
}
if (!titleMatches)
{
continue;
}
eligibleCount++;
}
finally
{
process.Dispose();
}
}
return new TornadoProcessSnapshot(totalCount, eligibleCount, programCount);
}
internal static bool IsTornadoProcessName(string? processName) =>
processName?.StartsWith(ProcessNamePrefix, StringComparison.OrdinalIgnoreCase) == true;
internal static bool IsProgramTitle(string? title) =>
!string.IsNullOrWhiteSpace(title) &&
(title.Contains("PGM", StringComparison.OrdinalIgnoreCase) ||
title.Contains("PROGRAM", StringComparison.OrdinalIgnoreCase));
private static bool IsProcessInspectionException(Exception exception) =>
exception is InvalidOperationException or
Win32Exception or
NotSupportedException or
UnauthorizedAccessException;
}