feat: add safe Tornado K3D playout adapter
This commit is contained in:
422
src/MBN_STOCK_WEBVIEW.Playout/Runtime/StaDispatcher.cs
Normal file
422
src/MBN_STOCK_WEBVIEW.Playout/Runtime/StaDispatcher.cs
Normal 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.")
|
||||
{
|
||||
}
|
||||
}
|
||||
125
src/MBN_STOCK_WEBVIEW.Playout/Runtime/TornadoProcessProbe.cs
Normal file
125
src/MBN_STOCK_WEBVIEW.Playout/Runtime/TornadoProcessProbe.cs
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user