diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 6410f60..198e781 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -34,6 +34,9 @@ jobs: - name: Restore Packages run: nuget restore NINASonyCameraPlugin.sln + + - name: Test capture coordination + run: dotnet test Tests/CaptureController.Tests.csproj --configuration Release - name: Build run: msbuild.exe NINASonyCameraPlugin.sln /nologo /nr:false /p:DeployOnBuild=true /p:DeployDefaultTarget=WebPublish /p:WebPublishMethod=FileSystem /p:DeleteExistingFiles=True /p:platform="Any CPU" /p:configuration="Release" /p:PublishUrl="../_build" @@ -43,4 +46,4 @@ jobs: with: name: SonyCamera-DLL path: | - **/bin/Release/**/NINA.RetroKiwi.Plugin.SonyCamera.dll \ No newline at end of file + **/bin/Release/**/NINA.RetroKiwi.Plugin.SonyCamera.dll diff --git a/CHANGELOG.md b/CHANGELOG.md index 870006f..da98988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ -# Sony Camera Plugin +# Sony Camera Plugin + +## 1.0.0.5 + +* Added a profile-specific native cancel option, defaulted to off to avoid abort-related crashes. +* Serialized native capture starts and status/cancel calls; busy or unknown states reject new exposures without cancelling the previous capture. +* Made aborts idempotent, kept their lock wait bounded, and prevented delayed aborts from cancelling a newer exposure. +* Reported cancelled and failed captures correctly rather than downloading a stale image. +* Added automated capture coordination regression tests. ## 1.0.0.4 * Added the `UpdateSubSampleArea` implementation and bumped the minimum NINA version so the plugin loads in 3.2. diff --git a/Drivers/CameraDriver.cs b/Drivers/CameraDriver.cs index f2d6826..d1a1958 100644 --- a/Drivers/CameraDriver.cs +++ b/Drivers/CameraDriver.cs @@ -29,15 +29,8 @@ public class CameraDriver : BaseINPC, ICamera { private const uint PROPID_ISO = 0xD21E; // Actual ISO currently set private const uint PROPID_ISOS = 0xFFFE; // Registry-backed list of learnt ISOs (may be empty until learnt) - // Capture Status - private const uint CAPTURE_CREATED = 0x0000; - private const uint CAPTURE_CAPTURING = 0x0001; - private const uint CAPTURE_FAILED = 0x0002; - private const uint CAPTURE_CANCELLED = 0x0003; - private const uint CAPTURE_COMPLETE = 0x0004; - private const uint CAPTURE_STARTING = 0x8001; - private const uint CAPTURE_READING = 0x8002; - private const uint CAPTURE_PROCESSING = 0x8003; + private readonly PluginOptionsAccessor _pluginSettings; + private readonly CaptureController _capture; private SonyCameraInfo _camera = null; private SonyDevice _device = null; @@ -48,10 +41,19 @@ public class CameraDriver : BaseINPC, ICamera { private short _readoutModeForNormalImages; private AsyncObservableCollection _binningModes; - public CameraDriver(IProfileService profileService, IExposureDataFactory exposureDataFactory, SonyDevice device) { + public CameraDriver(IProfileService profileService, IExposureDataFactory exposureDataFactory, SonyDevice device, PluginOptionsAccessor pluginSettings) { _profileService = profileService; _exposureDataFactory = exposureDataFactory; _device = device; + _pluginSettings = pluginSettings; + _capture = new CaptureController( + () => { + TryGetCaptureStatus(SonyDriver.GetInstance(), out var status, "capture status"); + return status; + }, + exposureTime => SonyDriver.GetInstance().StartCapture(_camera.Handle, exposureTime), + TryNativeCancel, + () => NativeCancelEnabled); } #region Internal Helpers @@ -91,6 +93,39 @@ private void NotifyGainPropertiesChanged() { RaisePropertyChanged(nameof(Gains)); } + private bool NativeCancelEnabled { + get { + try { + var raw = _pluginSettings.GetValueString(nameof(SonyCamera.EnableNativeCancel), bool.FalseString); + return bool.TryParse(raw, out var enabled) && enabled; + } catch (Exception ex) { + Logger.Warning($"EnableNativeCancel read failed; defaulting to false. {ex.Message}"); + return false; + } + } + } + + private bool TryNativeCancel() { + try { + SonyDriver.GetInstance().CancelCapture(_camera.Handle); + return true; + } catch (Exception ex) { + Logger.Error("CancelCapture failed; letting capture finish.", ex); + return false; + } + } + + private bool TryGetCaptureStatus(SonyDriver driver, out uint status, string reason) { + try { + status = driver.GetCaptureStatus(_camera.Handle); + return true; + } catch (Exception ex) { + Logger.Warning($"Unable to get capture status ({reason}); camera may not have responded: {ex.Message}."); + status = CaptureController.StatusUnknown; + return false; + } + } + #endregion #region Supported Properties @@ -516,19 +551,12 @@ public void SetupDialog() { public void StartExposure(CaptureSequence sequence) { if (_camera != null) { - SonyDriver driver = SonyDriver.GetInstance(); - uint captureStatus = driver.GetCaptureStatus(_camera.Handle); - - if (captureStatus == CAPTURE_CAPTURING || captureStatus == CAPTURE_PROCESSING || captureStatus == CAPTURE_STARTING || - captureStatus == CAPTURE_READING || captureStatus == CAPTURE_PROCESSING) { - Notification.ShowWarning("Another exposure still in progress. Cancelling it to start another."); + try { + _capture.Start((float)sequence.ExposureTime); + } catch (InvalidOperationException ex) { + Logger.Warning(ex.Message); + throw new SonyException(ex.Message); } - - // Tell the camera to cancel capture, we do this every time regardless - this will reset the status to be non-complete - driver.CancelCapture(_camera.Handle); - - double exposureTime = sequence.ExposureTime; - driver.StartCapture(_camera.Handle, (float)exposureTime); //); } } @@ -537,30 +565,25 @@ public void StopExposure() { } public void AbortExposure() { - if (_camera != null) { - SonyDriver.GetInstance().CancelCapture(_camera.Handle); + if (_camera == null) { + return; + } + + if (_capture.Abort() == CaptureController.AbortResult.SoftCancelRequested) { + Notification.ShowWarning("Abort requested. The camera will finish the current exposure before another can start. Native cancellation is disabled or unavailable."); + Logger.Info("AbortExposure requested; native cancel unavailable; letting capture finish."); } } public async Task WaitUntilExposureIsReady(CancellationToken token) { using (token.Register(AbortExposure)) { - uint[] completionStates = { CAPTURE_CANCELLED, CAPTURE_COMPLETE, CAPTURE_FAILED }; - - SonyDriver driver = SonyDriver.GetInstance(); - try { - uint captureStatus = driver.GetCaptureStatus(_camera.Handle); - Logger.Info( - $"Waiting for image to be ready, current state is {captureStatus}, completion states are {String.Join(", ", completionStates)}"); - - while (!completionStates.Contains(captureStatus)) { - await CoreUtil.Wait(TimeSpan.FromMilliseconds(100), token); - captureStatus = driver.GetCaptureStatus(_camera.Handle); - } - - Logger.Info($"Wait for image ready complete, completion state is {captureStatus}"); + await _capture.WaitUntilReady(token); + } catch (OperationCanceledException) { + Logger.Info("WaitUntilExposureIsReady cancelled."); + throw; } catch (Exception ex) { - Logger.Error("WaitUntilExposureIsReady got exception", ex); + Logger.Error("WaitUntilExposureIsReady got exception.", ex); throw new SonyException("Problem while waiting for image to be ready (see log)"); } } diff --git a/Drivers/CameraProvider.cs b/Drivers/CameraProvider.cs index 7c78287..d6d6447 100644 --- a/Drivers/CameraProvider.cs +++ b/Drivers/CameraProvider.cs @@ -2,6 +2,7 @@ using NINA.Equipment.Interfaces.ViewModel; using NINA.Equipment.Interfaces; using NINA.Profile.Interfaces; +using NINA.Profile; using System; using System.Collections.Generic; using System.Linq; @@ -24,11 +25,13 @@ public class CameraProvider : IEquipmentProvider { private IProfileService profileService; private IExposureDataFactory exposureDataFactory; SonyDriver driver; + private readonly PluginOptionsAccessor pluginSettings; [ImportingConstructor] public CameraProvider(IProfileService profileService, IExposureDataFactory exposureDataFactory) { this.profileService = profileService; this.exposureDataFactory = exposureDataFactory; + this.pluginSettings = new PluginOptionsAccessor(profileService, SonyCamera.PluginGuid); if (!DllLoader.IsX86()) { try { @@ -43,14 +46,13 @@ public CameraProvider(IProfileService profileService, IExposureDataFactory expos public IList GetEquipment() { var devices = new List(); - if (this.driver != null) { try { int count = 0; foreach (var sonyDevice in driver.Cameras()) { count++; - devices.Add(new CameraDriver(profileService, exposureDataFactory, sonyDevice)); + devices.Add(new CameraDriver(profileService, exposureDataFactory, sonyDevice, pluginSettings)); } Logger.Info($"Found {count} Sony Cameras"); diff --git a/Drivers/CaptureController.cs b/Drivers/CaptureController.cs new file mode 100644 index 0000000..fda0fdb --- /dev/null +++ b/Drivers/CaptureController.cs @@ -0,0 +1,131 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace NINA.RetroKiwi.Plugin.SonyCamera.Drivers { + // Keep capture coordination independent of NINA and the native DLL so races can be tested without a camera. + internal sealed class CaptureController { + internal const uint StatusUnknown = 0xFFFFFFFF; + private const uint Created = 0x0000; + private const uint Capturing = 0x0001; + private const uint Failed = 0x0002; + private const uint Cancelled = 0x0003; + private const uint Complete = 0x0004; + private const uint Starting = 0x8001; + private const uint Reading = 0x8002; + private const uint Processing = 0x8003; + + internal enum AbortResult { AlreadyRequested, NativeCancelRequested, SoftCancelRequested } + + private readonly object captureLock = new object(); + private readonly Func getStatus; + private readonly Action startCapture; + private readonly Func cancelCapture; + private readonly Func nativeCancelEnabled; + private readonly int cancelLockTimeoutMs; + private sealed class CaptureRequest { + internal int CancelRequested; + } + private CaptureRequest currentRequest = new CaptureRequest(); + + internal CaptureController(Func getStatus, Action startCapture, + Func cancelCapture, Func nativeCancelEnabled, int cancelLockTimeoutMs = 1000) { + this.getStatus = getStatus; + this.startCapture = startCapture; + this.cancelCapture = cancelCapture; + this.nativeCancelEnabled = nativeCancelEnabled; + this.cancelLockTimeoutMs = cancelLockTimeoutMs; + } + + internal void Start(float exposureTime) { + lock (captureLock) { + uint status = getStatus(); + if (IsBusy(status)) { + throw new InvalidOperationException("Cannot start exposure: camera is still busy with a previous exposure."); + } + if (status != Created && status != Cancelled && status != Complete && status != Failed) { + throw new InvalidOperationException($"Cannot start exposure: capture status unavailable or unexpected ({status})."); + } + + // A rejected start must not clear cancellation of the preceding exposure. + Volatile.Write(ref currentRequest, new CaptureRequest()); + // Serialize the native start itself: it can replace the native capture object. + startCapture(exposureTime); + } + } + + internal AbortResult Abort() { + // Record cancellation before waiting for the lock, including while StartCapture is blocked. + var request = Volatile.Read(ref currentRequest); + if (Interlocked.Exchange(ref request.CancelRequested, 1) != 0) { + return AbortResult.AlreadyRequested; + } + if (!nativeCancelEnabled() || !Monitor.TryEnter(captureLock, cancelLockTimeoutMs)) { + return AbortResult.SoftCancelRequested; + } + + try { + // An abort queued for the previous exposure must never cancel a new one. + if (!ReferenceEquals(request, currentRequest)) { + return AbortResult.AlreadyRequested; + } + uint status = getStatus(); + if (status == StatusUnknown || (!IsBusy(status) && status != Created && + status != Cancelled && status != Complete && status != Failed)) { + return AbortResult.SoftCancelRequested; + } + if (!IsBusy(status)) { + return AbortResult.AlreadyRequested; + } + return cancelCapture() ? AbortResult.NativeCancelRequested : AbortResult.SoftCancelRequested; + } finally { + Monitor.Exit(captureLock); + } + } + + internal async Task WaitUntilReady(CancellationToken token) { + var request = Volatile.Read(ref currentRequest); + while (true) { + token.ThrowIfCancellationRequested(); + ThrowIfCancelled(request); + uint status; + lock (captureLock) { + if (!ReferenceEquals(request, currentRequest)) { + throw new TaskCanceledException("Exposure superseded by a new capture."); + } + status = getStatus(); + } + token.ThrowIfCancellationRequested(); + ThrowIfCancelled(request); + + switch (status) { + case Complete: + return; + case Cancelled: + throw new TaskCanceledException("Exposure cancelled by camera."); + case Failed: + throw new InvalidOperationException("Camera capture failed."); + case Created: + case Capturing: + case Starting: + case Reading: + case Processing: + await Task.Delay(100, token); + break; + default: + throw new InvalidOperationException($"Capture status unavailable or unexpected ({status})."); + } + } + } + + private static void ThrowIfCancelled(CaptureRequest request) { + if (Volatile.Read(ref request.CancelRequested) != 0) { + throw new TaskCanceledException("Exposure cancelled by user; the camera may still be finishing it."); + } + } + + private static bool IsBusy(uint status) { + return status == Capturing || status == Starting || status == Reading || status == Processing; + } + } +} diff --git a/NINASonyCameraPlugin.csproj b/NINASonyCameraPlugin.csproj index 949eaca..608d7c4 100644 --- a/NINASonyCameraPlugin.csproj +++ b/NINASonyCameraPlugin.csproj @@ -40,6 +40,7 @@ + @@ -68,7 +69,7 @@ - + diff --git a/Options.xaml b/Options.xaml index 893755b..b7cb9c5 100644 --- a/Options.xaml +++ b/Options.xaml @@ -6,15 +6,12 @@ - - - - - - - - - - + + + + + - \ No newline at end of file + diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs index ad54320..b4e7953 100644 --- a/Properties/AssemblyInfo.cs +++ b/Properties/AssemblyInfo.cs @@ -8,8 +8,8 @@ // [MANDATORY] The assembly versioning //Should be incremented for each new release build of a plugin -[assembly: AssemblyVersion("1.0.0.4")] -[assembly: AssemblyFileVersion("1.0.0.4")] +[assembly: AssemblyVersion("1.0.0.5")] +[assembly: AssemblyFileVersion("1.0.0.5")] // [MANDATORY] The name of your plugin [assembly: AssemblyTitle("Sony Camera Plugin")] diff --git a/README.md b/README.md index a98c5bd..e429f4d 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ https://github.com/dougforpres/NINASonyCameraPlugin/releases - Binning and sub-sampling are not supported; captures use the full sensor frame. - For exposures <= 30s the driver chooses the nearest built-in shutter speed; longer exposures fall back to Bulb. - Powering off/unplugging the camera while connected can crash NINA via Windows' MTP stack (PortableDeviceApi.dll access violation); disconnect in NINA first to avoid it. +- “Enable native cancel” is off by default and saved per NINA profile. With it off, abort cancels the wait and discards the exposure, while the camera finishes capturing; another exposure can start once the camera finishes. Enabling it allows native cancellation during aborts, which may crash NINA on some camera models and Windows drivers. New exposure requests never cancel an existing capture. ## Support @@ -43,7 +44,7 @@ https://github.com/dougforpres/NINASonyCameraPlugin/releases ## Metadata -- Version: 1.0.0.4 +- Version: 1.0.0.5 - Author: Doug Henderson - Contributors: Lucas Lepski [@ShurkanTwo](https://github.com/shurkanTwo) - Minimum NINA version: 3.2.0.3001 diff --git a/SonyCameraPlugin.cs b/SonyCameraPlugin.cs index 6b7cfc0..74b6fbf 100644 --- a/SonyCameraPlugin.cs +++ b/SonyCameraPlugin.cs @@ -28,6 +28,7 @@ namespace NINA.RetroKiwi.Plugin.SonyCamera { /// [Export(typeof(IPluginManifest))] public class SonyCamera : PluginBase, INotifyPropertyChanged { + public static readonly Guid PluginGuid = Guid.Parse("f3fd7bb5-2b69-40cc-846f-4f4a2ff62518"); private readonly IPluginOptionsAccessor pluginSettings; private readonly IProfileService profileService; private readonly IImageSaveMediator imageSaveMediator; @@ -44,7 +45,7 @@ public SonyCamera(IProfileService profileService, IOptionsVM options, IImageSave } // This helper class can be used to store plugin settings that are dependent on the current profile - this.pluginSettings = new PluginOptionsAccessor(profileService, Guid.Parse(this.Identifier)); + this.pluginSettings = new PluginOptionsAccessor(profileService, PluginGuid); this.profileService = profileService; // React on a changed profile profileService.ProfileChanged += ProfileService_ProfileChanged; @@ -72,6 +73,7 @@ public override Task Teardown() { private void ProfileService_ProfileChanged(object sender, EventArgs e) { // Rase the event that this profile specific value has been changed due to the profile switch RaisePropertyChanged(nameof(ProfileSpecificNotificationMessage)); + RaisePropertyChanged(nameof(EnableNativeCancel)); } private Task ImageSaveMediator_BeforeImageSaved(object sender, BeforeImageSavedEventArgs e) { @@ -132,6 +134,17 @@ public string ProfileSpecificNotificationMessage { } } + public bool EnableNativeCancel { + get { + var raw = pluginSettings.GetValueString(nameof(EnableNativeCancel), bool.FalseString); + return bool.TryParse(raw, out var enabled) && enabled; + } + set { + pluginSettings.SetValueString(nameof(EnableNativeCancel), value.ToString()); + RaisePropertyChanged(); + } + } + public event PropertyChangedEventHandler PropertyChanged; protected void RaisePropertyChanged([CallerMemberName] string propertyName = null) { this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); diff --git a/Tests/CaptureController.Tests.csproj b/Tests/CaptureController.Tests.csproj new file mode 100644 index 0000000..b1c68cb --- /dev/null +++ b/Tests/CaptureController.Tests.csproj @@ -0,0 +1,17 @@ + + + net8.0 + true + false + false + + + + + + + all + + + + diff --git a/Tests/CaptureControllerTests.cs b/Tests/CaptureControllerTests.cs new file mode 100644 index 0000000..f52c0f1 --- /dev/null +++ b/Tests/CaptureControllerTests.cs @@ -0,0 +1,250 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using NINA.RetroKiwi.Plugin.SonyCamera.Drivers; +using Xunit; + +public class CaptureControllerTests { + private sealed class Camera { + internal uint Status = 2; // The native driver reports Failed when no capture exists. + internal bool NativeCancelEnabled; + internal bool CancelSucceeds = true; + internal int Starts; + internal int Cancels; + internal Action BeforeStart; + internal Action BeforeReadNativeCancel; + internal Func ReadStatus; + internal readonly CaptureController Capture; + + internal Camera() { + Capture = new CaptureController( + () => ReadStatus == null ? Status : ReadStatus(), + exposure => { + BeforeStart?.Invoke(); + Starts++; + Status = 1; + }, + () => { + Cancels++; + return CancelSucceeds; + }, + () => { + BeforeReadNativeCancel?.Invoke(); + return NativeCancelEnabled; + }, + cancelLockTimeoutMs: 20); + } + } + + [Theory] + [InlineData(1u)] + [InlineData(0x8001u)] + [InlineData(0x8002u)] + [InlineData(0x8003u)] + [InlineData(0xFFFFFFFFu)] + [InlineData(99u)] + public void BusyOrUnknownStartNeverCancelsPreviousExposure(uint status) { + var camera = new Camera { Status = status, NativeCancelEnabled = true }; + Assert.Throws(() => camera.Capture.Start(1)); + Assert.Equal(0, camera.Starts); + Assert.Equal(0, camera.Cancels); + } + + [Theory] + [InlineData(0u)] + [InlineData(2u)] + [InlineData(3u)] + [InlineData(4u)] + public void IdleStartDoesNotCallNativeCancel(uint status) { + var camera = new Camera { Status = status, NativeCancelEnabled = true }; + camera.Capture.Start(1); + Assert.Equal(1, camera.Starts); + Assert.Equal(0, camera.Cancels); + } + + [Fact] + public async Task SoftAbortStopsWaitingAndRejectsRestartUntilCameraFinishes() { + var camera = new Camera(); + camera.Capture.Start(1); + Assert.Equal(CaptureController.AbortResult.SoftCancelRequested, camera.Capture.Abort()); + Assert.Equal(CaptureController.AbortResult.AlreadyRequested, camera.Capture.Abort()); + Assert.Throws(() => camera.Capture.Start(1)); + await Assert.ThrowsAsync(() => camera.Capture.WaitUntilReady(CancellationToken.None)); + Assert.Equal(0, camera.Cancels); + + camera.Status = 4; + camera.Capture.Start(1); + camera.Status = 4; + await camera.Capture.WaitUntilReady(CancellationToken.None); + Assert.Equal(2, camera.Starts); + } + + [Fact] + public async Task NativeAbortIsIssuedOnceAndNeverReturnsAnImage() { + var camera = new Camera { NativeCancelEnabled = true }; + camera.Capture.Start(1); + Assert.Equal(CaptureController.AbortResult.NativeCancelRequested, camera.Capture.Abort()); + Assert.Equal(CaptureController.AbortResult.AlreadyRequested, camera.Capture.Abort()); + camera.Status = 4; + await Assert.ThrowsAsync(() => camera.Capture.WaitUntilReady(CancellationToken.None)); + Assert.Equal(1, camera.Cancels); + } + + [Theory] + [InlineData(3u, typeof(TaskCanceledException))] + [InlineData(2u, typeof(InvalidOperationException))] + [InlineData(0xFFFFFFFFu, typeof(InvalidOperationException))] + [InlineData(99u, typeof(InvalidOperationException))] + public async Task CancelledFailedAndUnknownCapturesNeverReportReady(uint status, Type errorType) { + var camera = new Camera(); + camera.Capture.Start(1); + camera.Status = status; + var error = await Record.ExceptionAsync(() => camera.Capture.WaitUntilReady(CancellationToken.None)); + Assert.IsType(errorType, error); + } + + [Fact] + public async Task CompleteStatusIsReadOnlyOnceBecauseNativeReadConsumesTheImage() { + var camera = new Camera(); + camera.Capture.Start(1); + int reads = 0; + camera.ReadStatus = () => ++reads == 1 ? 4u : 2u; + await camera.Capture.WaitUntilReady(CancellationToken.None); + Assert.Equal(1, reads); + } + + [Fact] + public async Task CancellationDuringStatusReadWinsOverImageCompletion() { + var camera = new Camera(); + camera.Capture.Start(1); + using var cancellation = new CancellationTokenSource(); + camera.ReadStatus = () => { + cancellation.Cancel(); + return 4; + }; + await Assert.ThrowsAnyAsync(() => camera.Capture.WaitUntilReady(cancellation.Token)); + } + + [Fact] + public async Task TokenCancellationInterruptsPolling() { + var camera = new Camera(); + camera.Capture.Start(1); + using var cancellation = new CancellationTokenSource(); + var wait = camera.Capture.WaitUntilReady(cancellation.Token); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => wait.WaitAsync(TimeSpan.FromSeconds(2))); + } + + [Fact] + public void NativeCancelSettingIsReadAtAbortTime() { + var camera = new Camera(); + camera.Capture.Start(1); + camera.NativeCancelEnabled = true; + Assert.Equal(CaptureController.AbortResult.NativeCancelRequested, camera.Capture.Abort()); + Assert.Equal(1, camera.Cancels); + } + + [Fact] + public async Task FailedNativeCancelFallsBackToSoftCancellation() { + var camera = new Camera { NativeCancelEnabled = true, CancelSucceeds = false }; + camera.Capture.Start(1); + Assert.Equal(CaptureController.AbortResult.SoftCancelRequested, camera.Capture.Abort()); + await Assert.ThrowsAsync(() => camera.Capture.WaitUntilReady(CancellationToken.None)); + } + + [Theory] + [InlineData(0xFFFFFFFFu)] + [InlineData(99u)] + public async Task UnknownStatusDuringAbortSkipsNativeCancel(uint status) { + var camera = new Camera { NativeCancelEnabled = true }; + camera.Capture.Start(1); + camera.Status = status; + Assert.Equal(CaptureController.AbortResult.SoftCancelRequested, camera.Capture.Abort()); + Assert.Equal(0, camera.Cancels); + await Assert.ThrowsAsync(() => camera.Capture.WaitUntilReady(CancellationToken.None)); + } + + [Fact] + public async Task CancellationAfterPollingBeginsInterruptsWaitWithoutAToken() { + var camera = new Camera(); + camera.Capture.Start(1); + var wait = camera.Capture.WaitUntilReady(CancellationToken.None); + camera.Capture.Abort(); + await Assert.ThrowsAsync(() => wait.WaitAsync(TimeSpan.FromSeconds(2))); + } + + [Fact] + public async Task AbortDuringBlockedNativeStartReturnsWithoutCallingNativeCancel() { + var camera = new Camera { NativeCancelEnabled = true }; + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + camera.BeforeStart = () => { + entered.Set(); + Assert.True(release.Wait(TimeSpan.FromSeconds(5))); + }; + var start = Task.Factory.StartNew(() => camera.Capture.Start(1), TaskCreationOptions.LongRunning); + try { + Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); + var abort = Task.Run(() => camera.Capture.Abort()); + Assert.Equal(CaptureController.AbortResult.SoftCancelRequested, + await abort.WaitAsync(TimeSpan.FromSeconds(2))); + Assert.Equal(0, camera.Cancels); + } finally { + release.Set(); + } + await start; + await Assert.ThrowsAsync(() => camera.Capture.WaitUntilReady(CancellationToken.None)); + } + + [Fact] + public async Task ConcurrentStartsCannotBothEnterNativeStart() { + var camera = new Camera(); + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + using var secondAttempted = new ManualResetEventSlim(); + camera.BeforeStart = () => { + entered.Set(); + Assert.True(release.Wait(TimeSpan.FromSeconds(5))); + }; + var first = Task.Factory.StartNew(() => camera.Capture.Start(1), TaskCreationOptions.LongRunning); + Task second = null; + try { + Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); + second = Task.Factory.StartNew(() => { + secondAttempted.Set(); + return Record.Exception(() => camera.Capture.Start(1)); + }, TaskCreationOptions.LongRunning); + Assert.True(secondAttempted.Wait(TimeSpan.FromSeconds(5))); + Assert.NotSame(second, await Task.WhenAny(second, Task.Delay(100))); + } finally { + release.Set(); + } + await first; + Assert.IsType(await second); + Assert.Equal(1, camera.Starts); + } + + [Fact] + public async Task DelayedAbortForPreviousExposureCannotCancelNextExposure() { + var camera = new Camera { NativeCancelEnabled = true }; + camera.Capture.Start(1); + camera.Status = 4; + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + camera.BeforeReadNativeCancel = () => { + entered.Set(); + Assert.True(release.Wait(TimeSpan.FromSeconds(5))); + }; + var abort = Task.Factory.StartNew(() => camera.Capture.Abort(), TaskCreationOptions.LongRunning); + try { + Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); + camera.Capture.Start(1); + } finally { + release.Set(); + } + Assert.Equal(CaptureController.AbortResult.AlreadyRequested, await abort); + Assert.Equal(0, camera.Cancels); + camera.Status = 4; + await camera.Capture.WaitUntilReady(CancellationToken.None); + } +}