fix: make STA Word queue lifetime safe
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("DocxWorkbench.Tests")]
|
||||||
@@ -6,6 +6,9 @@ internal sealed class StaWordQueue : IDisposable
|
|||||||
{
|
{
|
||||||
private readonly BlockingCollection<Action> _jobs = new();
|
private readonly BlockingCollection<Action> _jobs = new();
|
||||||
private readonly Thread _thread;
|
private readonly Thread _thread;
|
||||||
|
private readonly ManualResetEventSlim _stopped = new(false);
|
||||||
|
private readonly object _lifetimeGate = new();
|
||||||
|
private bool _accepting = true;
|
||||||
|
|
||||||
public StaWordQueue()
|
public StaWordQueue()
|
||||||
{
|
{
|
||||||
@@ -16,25 +19,42 @@ internal sealed class StaWordQueue : IDisposable
|
|||||||
|
|
||||||
public Task<T> Enqueue<T>(Func<T> operation, CancellationToken cancellationToken)
|
public Task<T> Enqueue<T>(Func<T> operation, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (operation is null) throw new ArgumentNullException(nameof(operation));
|
||||||
|
if (cancellationToken.IsCancellationRequested) return Task.FromCanceled<T>(cancellationToken);
|
||||||
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
_jobs.Add(() =>
|
lock (_lifetimeGate)
|
||||||
{
|
{
|
||||||
try { cancellationToken.ThrowIfCancellationRequested(); completion.SetResult(operation()); }
|
if (!_accepting) throw new ObjectDisposedException(nameof(StaWordQueue));
|
||||||
catch (OperationCanceledException ex) { completion.SetCanceled(ex.CancellationToken); }
|
try { _jobs.Add(() => Execute(operation, completion, cancellationToken), cancellationToken); }
|
||||||
catch (Exception ex) { completion.SetException(ex); }
|
catch (InvalidOperationException) { throw new ObjectDisposedException(nameof(StaWordQueue)); }
|
||||||
}, cancellationToken);
|
catch (OperationCanceledException) { completion.TrySetCanceled(cancellationToken); }
|
||||||
|
}
|
||||||
return completion.Task;
|
return completion.Task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void Execute<T>(Func<T> operation, TaskCompletionSource<T> completion, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try { cancellationToken.ThrowIfCancellationRequested(); completion.TrySetResult(operation()); }
|
||||||
|
catch (OperationCanceledException ex) { completion.TrySetCanceled(ex.CancellationToken); }
|
||||||
|
catch (Exception ex) { completion.TrySetException(ex); }
|
||||||
|
}
|
||||||
|
|
||||||
private void Run()
|
private void Run()
|
||||||
{
|
{
|
||||||
foreach (var job in _jobs.GetConsumingEnumerable()) job();
|
try { foreach (var job in _jobs.GetConsumingEnumerable()) job(); }
|
||||||
|
finally { _stopped.Set(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
lock (_lifetimeGate)
|
||||||
|
{
|
||||||
|
if (!_accepting) return;
|
||||||
|
_accepting = false;
|
||||||
_jobs.CompleteAdding();
|
_jobs.CompleteAdding();
|
||||||
if (Thread.CurrentThread != _thread) _thread.Join(TimeSpan.FromSeconds(5));
|
}
|
||||||
_jobs.Dispose();
|
if (Thread.CurrentThread != _thread) _stopped.Wait(TimeSpan.FromSeconds(30));
|
||||||
|
// Do not dispose the collection or stop event while the STA worker may
|
||||||
|
// still be draining it. The worker owns the final enumeration lifetime.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using DocxWorkbench.WordInterop;
|
||||||
|
|
||||||
|
namespace DocxWorkbench.Tests;
|
||||||
|
|
||||||
|
public sealed class StaWordQueueTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Dispose_DrainsActiveJobAndIsIdempotent()
|
||||||
|
{
|
||||||
|
using var queue = new StaWordQueue(); using var started = new ManualResetEventSlim(false); using var release = new ManualResetEventSlim(false);
|
||||||
|
var job = queue.Enqueue(() => { started.Set(); Assert.Equal(ApartmentState.STA, Thread.CurrentThread.GetApartmentState()); release.Wait(); return 42; }, CancellationToken.None); Assert.True(started.Wait(TimeSpan.FromSeconds(2)));
|
||||||
|
var dispose = Task.Run(queue.Dispose); var completed = await Task.WhenAny(dispose, Task.Delay(100)); Assert.NotSame(dispose, completed); release.Set(); await dispose; Assert.Equal(42, await job); queue.Dispose(); Assert.Throws<ObjectDisposedException>(() => EnqueueAfterDispose(queue));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Enqueue_CancellationCompletesCanceledTask()
|
||||||
|
{
|
||||||
|
using var queue = new StaWordQueue(); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); await Assert.ThrowsAnyAsync<OperationCanceledException>(() => queue.Enqueue(() => 1, cancellation.Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnqueueAfterDispose(StaWordQueue queue) => queue.Enqueue(() => 1, CancellationToken.None);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user