mirror of
https://github.com/cyanfish/naps2.git
synced 2024-10-05 20:07:42 +03:00
Escl: Job cleanup and minor fixes
This commit is contained in:
parent
0cb36e55f1
commit
9092870ee7
@ -113,16 +113,14 @@ internal class EsclApiController : WebApiController
|
||||
public async Task GetScannerStatus()
|
||||
{
|
||||
var jobsElement = new XElement(ScanNs + "Jobs");
|
||||
foreach (var jobInfo in _serverState.Jobs.Values)
|
||||
foreach (var jobInfo in _serverState.Jobs.OrderBy(x => x.LastUpdated.ElapsedMilliseconds))
|
||||
{
|
||||
jobsElement.Add(new XElement(ScanNs + "JobInfo",
|
||||
new XElement(PwgNs + "JobUri", $"/eSCL/ScanJobs/{jobInfo.Id}"),
|
||||
new XElement(PwgNs + "JobUuid", jobInfo.Id),
|
||||
new XElement(ScanNs + "Age", Math.Ceiling(jobInfo.LastUpdated.Elapsed.TotalSeconds)),
|
||||
// TODO: real data
|
||||
new XElement(PwgNs + "ImagesCompleted",
|
||||
jobInfo.State is EsclJobState.Pending or EsclJobState.Processing ? "0" : "1"),
|
||||
new XElement(PwgNs + "ImagesToTransfer", "1"),
|
||||
new XElement(PwgNs + "ImagesCompleted", jobInfo.ImagesCompleted),
|
||||
new XElement(PwgNs + "ImagesToTransfer", jobInfo.ImagesToTransfer),
|
||||
new XElement(PwgNs + "JobState", jobInfo.State.ToString()),
|
||||
new XElement(PwgNs + "JobStateReasons",
|
||||
new XElement(PwgNs + "JobStateReason",
|
||||
@ -164,16 +162,16 @@ internal class EsclApiController : WebApiController
|
||||
return;
|
||||
}
|
||||
_serverState.IsProcessing = true;
|
||||
var jobState = JobInfo.CreateNewJob(_serverState, _deviceConfig.CreateJob(settings));
|
||||
_serverState.Jobs[jobState.Id] = jobState;
|
||||
Response.Headers.Add("Location", $"{Request.Url}/{jobState.Id}");
|
||||
var jobInfo = JobInfo.CreateNewJob(_serverState, _deviceConfig.CreateJob(settings));
|
||||
_serverState.AddJob(jobInfo);
|
||||
Response.Headers.Add("Location", $"{Request.Url}/{jobInfo.Id}");
|
||||
Response.StatusCode = 201; // Created
|
||||
}
|
||||
|
||||
[Route(HttpVerbs.Delete, "/ScanJobs/{jobId}")]
|
||||
public void CancelScanJob(string jobId)
|
||||
{
|
||||
if (_serverState.Jobs.TryGetValue(jobId, out var jobState) &&
|
||||
if (_serverState.TryGetJob(jobId, out var jobState) &&
|
||||
jobState.State is EsclJobState.Pending or EsclJobState.Processing)
|
||||
{
|
||||
jobState.Job.Cancel();
|
||||
@ -195,7 +193,7 @@ internal class EsclApiController : WebApiController
|
||||
[Route(HttpVerbs.Get, "/ScanJobs/{jobId}/Progress")]
|
||||
public async Task Progress(string jobId)
|
||||
{
|
||||
if (_serverState.Jobs.TryGetValue(jobId, out var jobState) &&
|
||||
if (_serverState.TryGetJob(jobId, out var jobState) &&
|
||||
jobState.State is EsclJobState.Pending or EsclJobState.Processing)
|
||||
{
|
||||
SetChunkedResponse();
|
||||
@ -216,7 +214,7 @@ internal class EsclApiController : WebApiController
|
||||
[Route(HttpVerbs.Get, "/ScanJobs/{jobId}/ErrorDetails")]
|
||||
public async Task ErrorDetails(string jobId)
|
||||
{
|
||||
if (_serverState.Jobs.TryGetValue(jobId, out var jobState))
|
||||
if (_serverState.TryGetJob(jobId, out var jobState))
|
||||
{
|
||||
Response.ContentType = "text/xml";
|
||||
using var stream = Response.OutputStream;
|
||||
@ -231,7 +229,7 @@ internal class EsclApiController : WebApiController
|
||||
[Route(HttpVerbs.Get, "/ScanJobs/{jobId}/NextDocument")]
|
||||
public async Task NextDocument(string jobId)
|
||||
{
|
||||
if (!_serverState.Jobs.TryGetValue(jobId, out var jobInfo))
|
||||
if (!_serverState.TryGetJob(jobId, out var jobInfo))
|
||||
{
|
||||
Response.StatusCode = 404;
|
||||
return;
|
||||
@ -255,9 +253,6 @@ internal class EsclApiController : WebApiController
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "ESCL server error waiting for document");
|
||||
// The ScanJob should transition the job to aborted on error, but there's a race condition where this could
|
||||
// happen first and another NextDocument call will think the job is still processing unless we ensure it's
|
||||
// in the correct state here.
|
||||
jobInfo.TransitionState(EsclJobState.Processing, EsclJobState.Aborted);
|
||||
Response.StatusCode = 500;
|
||||
return;
|
||||
@ -270,6 +265,7 @@ internal class EsclApiController : WebApiController
|
||||
Response.ContentEncoding = null;
|
||||
using var stream = Response.OutputStream;
|
||||
await jobInfo.Job.WriteDocumentTo(stream);
|
||||
jobInfo.TransferredDocument();
|
||||
}
|
||||
else
|
||||
{
|
||||
|
@ -85,21 +85,26 @@ public class EsclServer : IEsclServer
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
public Task Stop()
|
||||
{
|
||||
if (!_started)
|
||||
{
|
||||
return;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
_started = false;
|
||||
|
||||
_cts!.Cancel();
|
||||
var tasks = new List<Task>();
|
||||
foreach (var device in _devices.Keys)
|
||||
{
|
||||
var deviceCtx = _devices[device];
|
||||
deviceCtx.StartTask?.ContinueWith(_ => deviceCtx.Advertiser.UnadvertiseDevice(device));
|
||||
if (deviceCtx.StartTask != null)
|
||||
{
|
||||
tasks.Add(deviceCtx.StartTask.ContinueWith(_ => deviceCtx.Advertiser.UnadvertiseDevice(device)));
|
||||
}
|
||||
}
|
||||
_cts = null;
|
||||
return Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
@ -1,8 +1,52 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace NAPS2.Escl.Server;
|
||||
|
||||
internal class EsclServerState
|
||||
{
|
||||
private const int CLEANUP_INTERVAL = 10_000;
|
||||
|
||||
private Timer? _cleanupTimer;
|
||||
private readonly Dictionary<string, JobInfo> _jobDict = new();
|
||||
|
||||
public bool IsProcessing { get; set; }
|
||||
|
||||
public Dictionary<string, JobInfo> Jobs { get; } = new();
|
||||
public IEnumerable<JobInfo> Jobs => _jobDict.Values.ToList();
|
||||
|
||||
public void AddJob(JobInfo jobInfo)
|
||||
{
|
||||
lock (this)
|
||||
{
|
||||
_jobDict.Add(jobInfo.Id, jobInfo);
|
||||
_cleanupTimer ??= new Timer(Cleanup, null, CLEANUP_INTERVAL, CLEANUP_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetJob(string id, [MaybeNullWhen(false)] out JobInfo jobInfo)
|
||||
{
|
||||
lock (this)
|
||||
{
|
||||
return _jobDict.TryGetValue(id, out jobInfo);
|
||||
}
|
||||
}
|
||||
|
||||
private void Cleanup(object? state)
|
||||
{
|
||||
lock (this)
|
||||
{
|
||||
foreach (var jobInfo in Jobs)
|
||||
{
|
||||
if (jobInfo.IsEligibleForCleanup)
|
||||
{
|
||||
_jobDict.Remove(jobInfo.Id);
|
||||
jobInfo.Job.Dispose();
|
||||
if (_jobDict.Count == 0)
|
||||
{
|
||||
_cleanupTimer?.Dispose();
|
||||
_cleanupTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -22,10 +22,14 @@ internal class FakeEsclScanJob : IEsclScanJob
|
||||
{
|
||||
var bytes = File.ReadAllBytes(@"C:\Devel\VS\NAPS2\NAPS2.Sdk.Tests\Resources\dog.jpg");
|
||||
await stream.WriteAsync(bytes, 0, bytes.Length);
|
||||
_callback?.Invoke(StatusTransition.DeviceIdle);
|
||||
_callback?.Invoke(StatusTransition.ScanComplete);
|
||||
}
|
||||
|
||||
public Task WriteProgressTo(Stream stream) => Task.CompletedTask;
|
||||
|
||||
public Task WriteErrorDetailsTo(Stream stream) => Task.CompletedTask;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
@ -23,9 +23,14 @@ internal class JobInfo
|
||||
{
|
||||
jobInfo.TransitionState(EsclJobState.Processing, EsclJobState.Aborted);
|
||||
}
|
||||
if (transition == StatusTransition.DeviceIdle)
|
||||
if (transition == StatusTransition.PageComplete)
|
||||
{
|
||||
jobInfo.NewImageToTransfer();
|
||||
}
|
||||
if (transition == StatusTransition.ScanComplete)
|
||||
{
|
||||
serverState.IsProcessing = false;
|
||||
jobInfo.IsScanComplete = true;
|
||||
}
|
||||
});
|
||||
return jobInfo;
|
||||
@ -35,10 +40,25 @@ internal class JobInfo
|
||||
|
||||
public required EsclJobState State { get; set; }
|
||||
|
||||
public int ImagesCompleted { get; set; }
|
||||
|
||||
public int ImagesToTransfer { get; set; }
|
||||
|
||||
public required Stopwatch LastUpdated { get; set; }
|
||||
|
||||
public required IEsclScanJob Job { get; set; }
|
||||
|
||||
// This is different than EsclJobState.Completed; the ESCL state only transitions to completed once the client has
|
||||
// finished querying for documents. IsScanComplete is set to true immediately after the physical scan operation is
|
||||
// done.
|
||||
public bool IsScanComplete { get; private set; }
|
||||
|
||||
private bool StateIsTerminal => State is EsclJobState.Completed or EsclJobState.Aborted or EsclJobState.Canceled;
|
||||
|
||||
public bool IsEligibleForCleanup => IsScanComplete &&
|
||||
(StateIsTerminal && LastUpdated.Elapsed > TimeSpan.FromMinutes(1) ||
|
||||
LastUpdated.Elapsed > TimeSpan.FromMinutes(5));
|
||||
|
||||
public void TransitionState(EsclJobState precondition, EsclJobState newState)
|
||||
{
|
||||
lock (this)
|
||||
@ -50,4 +70,32 @@ internal class JobInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void NewImageToTransfer()
|
||||
{
|
||||
lock (this)
|
||||
{
|
||||
ImagesToTransfer++;
|
||||
LastUpdated = Stopwatch.StartNew();
|
||||
}
|
||||
}
|
||||
|
||||
public void TransferredDocument()
|
||||
{
|
||||
lock (this)
|
||||
{
|
||||
if (Job.ContentType == "application/pdf")
|
||||
{
|
||||
// Assume all images transferred at once
|
||||
ImagesCompleted += ImagesToTransfer;
|
||||
ImagesToTransfer = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
ImagesCompleted++;
|
||||
ImagesToTransfer--;
|
||||
}
|
||||
LastUpdated = Stopwatch.StartNew();
|
||||
}
|
||||
}
|
||||
}
|
@ -9,6 +9,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="EmbedIO" Version="3.5.2" />
|
||||
<PackageReference Include="Nullable" Version="1.3.1" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
@ -90,7 +90,6 @@ public class EsclClient
|
||||
var content = new StringContent(doc, Encoding.UTF8, "text/xml");
|
||||
var url = GetUrl($"/{_service.RootUrl}/ScanJobs");
|
||||
Logger.LogDebug("ESCL POST {Url}", url);
|
||||
Logger.LogDebug("{Doc}", doc);
|
||||
var response = await HttpClient.PostAsync(url, content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
Logger.LogDebug("POST OK");
|
||||
@ -103,6 +102,7 @@ public class EsclClient
|
||||
|
||||
public async Task<RawDocument?> NextDocument(EsclJob job, Action<double>? pageProgress = null)
|
||||
{
|
||||
var progressCts = new CancellationTokenSource();
|
||||
if (pageProgress != null)
|
||||
{
|
||||
var progressUrl = GetUrl($"{job.UriPath}/Progress");
|
||||
@ -110,8 +110,13 @@ public class EsclClient
|
||||
var streamReader = new StreamReader(progressResponse);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
using var streamReaderForDisposal = streamReader;
|
||||
while (await streamReader.ReadLineAsync() is { } line)
|
||||
{
|
||||
if (progressCts.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (double.TryParse(line, NumberStyles.Any, CultureInfo.InvariantCulture, out var progress))
|
||||
{
|
||||
pageProgress(progress);
|
||||
@ -119,25 +124,33 @@ public class EsclClient
|
||||
}
|
||||
});
|
||||
}
|
||||
// TODO: Maybe check Content-Location on the response header to ensure no duplicate document?
|
||||
var url = GetUrl($"{job.UriPath}/NextDocument");;
|
||||
Logger.LogDebug("ESCL GET {Url}", url);
|
||||
var response = await DocumentHttpClient.GetAsync(url);
|
||||
if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone)
|
||||
try
|
||||
{
|
||||
// NotFound = end of scan, Gone = canceled
|
||||
Logger.LogDebug("GET failed: {Status}", response.StatusCode);
|
||||
return null;
|
||||
// TODO: Maybe check Content-Location on the response header to ensure no duplicate document?
|
||||
var url = GetUrl($"{job.UriPath}/NextDocument");
|
||||
Logger.LogDebug("ESCL GET {Url}", url);
|
||||
var response = await DocumentHttpClient.GetAsync(url);
|
||||
if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone)
|
||||
{
|
||||
// NotFound = end of scan, Gone = canceled
|
||||
Logger.LogDebug("GET failed: {Status}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
response.EnsureSuccessStatusCode();
|
||||
var doc = new RawDocument
|
||||
{
|
||||
Data = await response.Content.ReadAsByteArrayAsync(),
|
||||
ContentType = response.Content.Headers.ContentType?.MediaType,
|
||||
ContentLocation = response.Content.Headers.ContentLocation?.ToString()
|
||||
};
|
||||
Logger.LogDebug("GET OK: {Type} ({Bytes} bytes) {Location}", doc.ContentType, doc.Data.Length,
|
||||
doc.ContentLocation);
|
||||
return doc;
|
||||
}
|
||||
response.EnsureSuccessStatusCode();
|
||||
var doc = new RawDocument
|
||||
finally
|
||||
{
|
||||
Data = await response.Content.ReadAsByteArrayAsync(),
|
||||
ContentType = response.Content.Headers.ContentType?.MediaType,
|
||||
ContentLocation = response.Content.Headers.ContentLocation?.ToString()
|
||||
};
|
||||
Logger.LogDebug("{Type} ({Bytes} bytes) {Location}", doc.ContentType, doc.Data.Length, doc.ContentLocation);
|
||||
return doc;
|
||||
progressCts.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> ErrorDetails(EsclJob job)
|
||||
@ -146,6 +159,7 @@ public class EsclClient
|
||||
Logger.LogDebug("ESCL GET {Url}", url);
|
||||
var response = await HttpClient.GetAsync(url);
|
||||
response.EnsureSuccessStatusCode();
|
||||
Logger.LogDebug("GET OK");
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
|
||||
@ -170,9 +184,9 @@ public class EsclClient
|
||||
Logger.LogDebug("ESCL GET {Url}", url);
|
||||
var response = await HttpClient.GetAsync(url, CancelToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
Logger.LogDebug("GET OK");
|
||||
var text = await response.Content.ReadAsStringAsync();
|
||||
var doc = XDocument.Parse(text);
|
||||
Logger.LogTrace("{Doc}", doc);
|
||||
return doc;
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
namespace NAPS2.Escl.Server;
|
||||
|
||||
public interface IEsclScanJob
|
||||
public interface IEsclScanJob : IDisposable
|
||||
{
|
||||
string ContentType { get; }
|
||||
void Cancel();
|
||||
|
@ -7,6 +7,6 @@ public interface IEsclServer : IDisposable
|
||||
void AddDevice(EsclDeviceConfig deviceConfig);
|
||||
void RemoveDevice(EsclDeviceConfig deviceConfig);
|
||||
Task Start();
|
||||
void Stop();
|
||||
Task Stop();
|
||||
ILogger Logger { get; set; }
|
||||
}
|
@ -4,5 +4,6 @@ public enum StatusTransition
|
||||
{
|
||||
CancelJob,
|
||||
AbortJob,
|
||||
DeviceIdle
|
||||
ScanComplete,
|
||||
PageComplete
|
||||
}
|
@ -64,7 +64,7 @@ public class AutoSaverTests : ContextualTests
|
||||
FilePath = Path.Combine(FolderPath, "test$(n).pdf")
|
||||
};
|
||||
|
||||
var scanned = CreateScannedImages(ImageResources.dog).ToList();
|
||||
var scanned = CreateScannedImages(ImageResources.dog);
|
||||
var output = await _autoSaver.Save(settings, scanned.ToAsyncEnumerable()).ToListAsync();
|
||||
|
||||
Assert.Single(output);
|
||||
@ -82,7 +82,7 @@ public class AutoSaverTests : ContextualTests
|
||||
FilePath = Path.Combine(FolderPath, "test$(n).jpg")
|
||||
};
|
||||
|
||||
var scanned = CreateScannedImages(ImageResources.dog).ToList();
|
||||
var scanned = CreateScannedImages(ImageResources.dog);
|
||||
var output = await _autoSaver.Save(settings, scanned.ToAsyncEnumerable()).ToListAsync();
|
||||
|
||||
Assert.Single(output);
|
||||
@ -187,7 +187,7 @@ public class AutoSaverTests : ContextualTests
|
||||
ImageResources.dog,
|
||||
ImageResources.dog_gray,
|
||||
ImageResources.patcht,
|
||||
ImageResources.dog_h_n300).ToList();
|
||||
ImageResources.dog_h_n300);
|
||||
scanned[2] = scanned[2].WithPostProcessingData(scanned[2].PostProcessingData with
|
||||
{
|
||||
Barcode = new Barcode(true, true, "PATCHT")
|
||||
|
@ -26,9 +26,9 @@ public class NetworkSharingSample
|
||||
scanServer.RegisterDevice(device);
|
||||
|
||||
// Run the server until the user presses Enter
|
||||
scanServer.Start();
|
||||
await scanServer.Start();
|
||||
Console.ReadLine();
|
||||
scanServer.Stop();
|
||||
await scanServer.Stop();
|
||||
}
|
||||
|
||||
public static async Task Client()
|
||||
|
@ -52,12 +52,9 @@ public class ContextualTests : IDisposable
|
||||
return ScanningContext.CreateProcessedImage(LoadImage(ImageResources.dog));
|
||||
}
|
||||
|
||||
public IEnumerable<ProcessedImage> CreateScannedImages(params byte[][] images)
|
||||
public List<ProcessedImage> CreateScannedImages(params byte[][] images)
|
||||
{
|
||||
foreach (var image in images)
|
||||
{
|
||||
yield return ScanningContext.CreateProcessedImage(LoadImage(image));
|
||||
}
|
||||
return images.Select(image => ScanningContext.CreateProcessedImage(LoadImage(image))).ToList();
|
||||
}
|
||||
|
||||
public void SetUpFileStorage()
|
||||
|
@ -9,6 +9,8 @@ internal class MockScanBridge : IScanBridge
|
||||
public List<ScanDevice> MockDevices { get; set; } = [];
|
||||
|
||||
public List<ProcessedImage> MockOutput { get; set; } = [];
|
||||
|
||||
public List<double> ProgressReports { get; set; } = [];
|
||||
|
||||
public Exception Error { get; set; }
|
||||
|
||||
@ -38,6 +40,11 @@ internal class MockScanBridge : IScanBridge
|
||||
{
|
||||
foreach (var img in MockOutput)
|
||||
{
|
||||
scanEvents.PageStart();
|
||||
foreach (var progress in ProgressReports)
|
||||
{
|
||||
scanEvents.PageProgress(progress);
|
||||
}
|
||||
callback(img.Clone(), new PostProcessingContext());
|
||||
}
|
||||
if (Error != null)
|
||||
|
@ -24,16 +24,13 @@ public class ScanServerIntegrationTests : ContextualTests
|
||||
_server = new ScanServer(ScanningContext, new EsclServer());
|
||||
|
||||
// Set up a server connecting to a mock scan backend
|
||||
_bridge = new MockScanBridge
|
||||
{
|
||||
MockOutput = [CreateScannedImage()]
|
||||
};
|
||||
_bridge = new MockScanBridge();
|
||||
var scanBridgeFactory = Substitute.For<IScanBridgeFactory>();
|
||||
scanBridgeFactory.Create(Arg.Any<ScanOptions>()).Returns(_bridge);
|
||||
_server.ScanController = new ScanController(ScanningContext, scanBridgeFactory);
|
||||
|
||||
// Initialize the server with a single device with a unique ID for the test
|
||||
var displayName = $"testName{Guid.NewGuid()}";
|
||||
var displayName = $"testName-{Guid.NewGuid()}";
|
||||
ScanningContext.Logger.LogDebug("Display name: {Name}", displayName);
|
||||
var serverDevice = new ScanDevice(ScanOptionsValidator.SystemDefaultDriver, "testID", "testName");
|
||||
var serverSharedDevice = new SharedDevice { Device = serverDevice, Name = displayName };
|
||||
@ -49,7 +46,7 @@ public class ScanServerIntegrationTests : ContextualTests
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_server.Dispose();
|
||||
_server.Stop().Wait();
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
@ -65,6 +62,7 @@ public class ScanServerIntegrationTests : ContextualTests
|
||||
[Fact]
|
||||
public async Task Scan()
|
||||
{
|
||||
_bridge.MockOutput = CreateScannedImages(ImageResources.dog);
|
||||
var images = await _client.Scan(new ScanOptions
|
||||
{
|
||||
Device = _clientDevice
|
||||
@ -77,7 +75,7 @@ public class ScanServerIntegrationTests : ContextualTests
|
||||
public async Task ScanMultiplePages()
|
||||
{
|
||||
_bridge.MockOutput =
|
||||
CreateScannedImages(ImageResources.dog, ImageResources.dog_h_n300, ImageResources.dog_h_p300).ToList();
|
||||
CreateScannedImages(ImageResources.dog, ImageResources.dog_h_n300, ImageResources.dog_h_p300);
|
||||
var images = await _client.Scan(new ScanOptions
|
||||
{
|
||||
Device = _clientDevice,
|
||||
@ -92,6 +90,7 @@ public class ScanServerIntegrationTests : ContextualTests
|
||||
[Fact]
|
||||
public async Task ScanWithCorrectOptions()
|
||||
{
|
||||
_bridge.MockOutput = CreateScannedImages(ImageResources.dog);
|
||||
var images = await _client.Scan(new ScanOptions
|
||||
{
|
||||
Device = _clientDevice,
|
||||
@ -111,7 +110,7 @@ public class ScanServerIntegrationTests : ContextualTests
|
||||
Assert.Single(images);
|
||||
ImageAsserts.Similar(ImageResources.dog, images[0]);
|
||||
|
||||
_bridge.MockOutput = CreateScannedImages(ImageResources.dog_gray).ToList();
|
||||
_bridge.MockOutput = CreateScannedImages(ImageResources.dog_gray);
|
||||
images = await _client.Scan(new ScanOptions
|
||||
{
|
||||
Device = _clientDevice,
|
||||
@ -131,7 +130,7 @@ public class ScanServerIntegrationTests : ContextualTests
|
||||
Assert.Single(images);
|
||||
ImageAsserts.Similar(ImageResources.dog_gray, images[0]);
|
||||
|
||||
_bridge.MockOutput = CreateScannedImages(ImageResources.dog_bw).ToList();
|
||||
_bridge.MockOutput = CreateScannedImages(ImageResources.dog_bw);
|
||||
images = await _client.Scan(new ScanOptions
|
||||
{
|
||||
Device = _clientDevice,
|
||||
@ -160,7 +159,51 @@ public class ScanServerIntegrationTests : ContextualTests
|
||||
|
||||
await Assert.ThrowsAsync<NoPagesException>(async () => await _client.Scan(new ScanOptions
|
||||
{
|
||||
Device = _clientDevice
|
||||
Device = _clientDevice,
|
||||
PaperSource = PaperSource.Feeder
|
||||
}).ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScanWithErrorAfterPage()
|
||||
{
|
||||
_bridge.MockOutput = CreateScannedImages(ImageResources.dog);
|
||||
_bridge.Error = new DeviceException(SdkResources.DevicePaperJam);
|
||||
|
||||
await using var enumerator = _client.Scan(new ScanOptions
|
||||
{
|
||||
Device = _clientDevice,
|
||||
PaperSource = PaperSource.Feeder
|
||||
}).GetAsyncEnumerator();
|
||||
|
||||
Assert.True(await enumerator.MoveNextAsync());
|
||||
ImageAsserts.Similar(ImageResources.dog, enumerator.Current);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<DeviceException>(async () => await enumerator.MoveNextAsync());
|
||||
Assert.Equal(SdkResources.DevicePaperJam, exception.Message);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Flaky")]
|
||||
public async Task ScanProgress()
|
||||
{
|
||||
_bridge.MockOutput = CreateScannedImages(ImageResources.dog, ImageResources.dog);
|
||||
_bridge.ProgressReports = [0.5];
|
||||
|
||||
var pageStartMock = Substitute.For<EventHandler<PageStartEventArgs>>();
|
||||
var pageProgressMock = Substitute.For<EventHandler<PageProgressEventArgs>>();
|
||||
_client.PageStart += pageStartMock;
|
||||
_client.PageProgress += pageProgressMock;
|
||||
|
||||
await _client.Scan(new ScanOptions
|
||||
{
|
||||
Device = _clientDevice,
|
||||
PaperSource = PaperSource.Feeder
|
||||
}).ToListAsync();
|
||||
|
||||
pageStartMock.Received()(Arg.Any<object>(), Arg.Is<PageStartEventArgs>(args => args.PageNumber == 1));
|
||||
// TODO: This flaked and we only got the second one - why? Can we fix it?
|
||||
pageProgressMock.Received()(Arg.Any<object>(), Arg.Is<PageProgressEventArgs>(args => args.PageNumber == 1 && args.Progress == 0.5));
|
||||
pageStartMock.Received()(Arg.Any<object>(), Arg.Is<PageStartEventArgs>(args => args.PageNumber == 2));
|
||||
pageProgressMock.Received()(Arg.Any<object>(), Arg.Is<PageProgressEventArgs>(args => args.PageNumber == 2 && args.Progress == 0.5));
|
||||
}
|
||||
}
|
@ -12,11 +12,16 @@ internal class ScanJob : IEsclScanJob
|
||||
{
|
||||
private readonly ScanningContext _scanningContext;
|
||||
private readonly ScanController _controller;
|
||||
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly IAsyncEnumerator<ProcessedImage> _enumerable;
|
||||
private readonly TaskCompletionSource<bool> _completedTcs = new();
|
||||
private readonly IAsyncEnumerator<ProcessedImage> _enumerable;
|
||||
private readonly List<ProcessedImage> _allImages = [];
|
||||
private readonly List<ProcessedImage> _pdfImages = [];
|
||||
private Action<StatusTransition>? _callback;
|
||||
private readonly Dictionary<int, double> _lastProgressByPageNumber = new();
|
||||
|
||||
private int _currentPage = 1;
|
||||
private Action<StatusTransition>? _statusCallback;
|
||||
private Exception? _lastError;
|
||||
|
||||
public ScanJob(ScanningContext scanningContext, ScanController controller, ScanDevice device,
|
||||
@ -24,14 +29,19 @@ internal class ScanJob : IEsclScanJob
|
||||
{
|
||||
_scanningContext = scanningContext;
|
||||
_controller = controller;
|
||||
_controller.PageProgress += (_, args) => _lastProgressByPageNumber[args.PageNumber] = args.Progress;
|
||||
_controller.PageEnd += (_, args) =>
|
||||
{
|
||||
_statusCallback?.Invoke(StatusTransition.PageComplete);
|
||||
_allImages.Add(args.Image);
|
||||
};
|
||||
_controller.ScanEnd += (_, args) =>
|
||||
{
|
||||
_callback?.Invoke(StatusTransition.DeviceIdle);
|
||||
if (args.HasError)
|
||||
{
|
||||
_lastError = args.Error;
|
||||
_callback?.Invoke(StatusTransition.AbortJob);
|
||||
}
|
||||
_statusCallback?.Invoke(StatusTransition.ScanComplete);
|
||||
_completedTcs.TrySetResult(!args.HasError);
|
||||
};
|
||||
var options = new ScanOptions
|
||||
@ -67,8 +77,8 @@ internal class ScanJob : IEsclScanJob
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_callback?.Invoke(StatusTransition.DeviceIdle);
|
||||
_callback?.Invoke(StatusTransition.AbortJob);
|
||||
_statusCallback?.Invoke(StatusTransition.AbortJob);
|
||||
_statusCallback?.Invoke(StatusTransition.ScanComplete);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@ -90,12 +100,12 @@ internal class ScanJob : IEsclScanJob
|
||||
public void Cancel()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_callback?.Invoke(StatusTransition.CancelJob);
|
||||
_statusCallback?.Invoke(StatusTransition.CancelJob);
|
||||
}
|
||||
|
||||
public void RegisterStatusTransitionCallback(Action<StatusTransition> callback)
|
||||
{
|
||||
_callback = callback;
|
||||
_statusCallback = callback;
|
||||
}
|
||||
|
||||
public async Task<bool> WaitForNextDocument()
|
||||
@ -109,12 +119,18 @@ internal class ScanJob : IEsclScanJob
|
||||
}
|
||||
do
|
||||
{
|
||||
_currentPage++;
|
||||
_pdfImages.Add(_enumerable.Current);
|
||||
} while (await _enumerable.MoveNextAsync());
|
||||
return true;
|
||||
}
|
||||
|
||||
return await _enumerable.MoveNextAsync();
|
||||
if (await _enumerable.MoveNextAsync())
|
||||
{
|
||||
_currentPage++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task WriteDocumentTo(Stream stream)
|
||||
@ -143,23 +159,27 @@ internal class ScanJob : IEsclScanJob
|
||||
|
||||
public async Task WriteProgressTo(Stream stream)
|
||||
{
|
||||
if (_completedTcs.Task.IsCompleted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pageEndTcs = new TaskCompletionSource<bool>();
|
||||
var streamWriter = new StreamWriter(stream);
|
||||
var pageNumber = _currentPage;
|
||||
|
||||
void OnPageProgress(object? sender, PageProgressEventArgs e)
|
||||
void WriteProgress(double progress)
|
||||
{
|
||||
streamWriter.WriteLine(e.Progress.ToString(CultureInfo.InvariantCulture));
|
||||
streamWriter.WriteLine(progress.ToString(CultureInfo.InvariantCulture));
|
||||
streamWriter.Flush();
|
||||
}
|
||||
|
||||
void OnPageEnd(object? sender, PageEndEventArgs e)
|
||||
void OnPageProgress(object? sender, PageProgressEventArgs e)
|
||||
{
|
||||
pageEndTcs.TrySetResult(true);
|
||||
if (e.PageNumber == pageNumber)
|
||||
{
|
||||
WriteProgress(e.Progress);
|
||||
}
|
||||
}
|
||||
void OnPageEnd(object? sender, PageEndEventArgs e) => pageEndTcs.TrySetResult(true);
|
||||
|
||||
if (_lastProgressByPageNumber.TryGetValue(pageNumber, out var lastPageProgress))
|
||||
{
|
||||
WriteProgress(lastPageProgress);
|
||||
}
|
||||
|
||||
_controller.PageProgress += OnPageProgress;
|
||||
@ -177,4 +197,12 @@ internal class ScanJob : IEsclScanJob
|
||||
await streamWriter.WriteLineAsync(RemotingHelper.ToError(_lastError).ToXml());
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var image in _allImages)
|
||||
{
|
||||
image.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
@ -84,7 +84,7 @@ public class ScanServer : IDisposable
|
||||
|
||||
public Task Start() => _esclServer.Start();
|
||||
|
||||
public void Stop() => _esclServer.Stop();
|
||||
public Task Stop() => _esclServer.Stop();
|
||||
|
||||
public void Dispose() => _esclServer.Dispose();
|
||||
}
|
@ -36,7 +36,11 @@ internal class RemoteScanController : IRemoteScanController
|
||||
{
|
||||
var driver = _scanDriverFactory.Create(options);
|
||||
var progressThrottle = new EventThrottle<double>(scanEvents.PageProgress);
|
||||
var driverScanEvents = new ScanEvents(scanEvents.PageStart, progressThrottle.OnlyIfChanged);
|
||||
var driverScanEvents = new ScanEvents(() =>
|
||||
{
|
||||
scanEvents.PageStart();
|
||||
progressThrottle.Reset();
|
||||
}, progressThrottle.OnlyIfChanged);
|
||||
int pageNumber = 0;
|
||||
await driver.Scan(options, cancelToken, driverScanEvents, image =>
|
||||
{
|
||||
|
@ -139,11 +139,13 @@ public class ScanController
|
||||
Exception? scanError = null;
|
||||
void ScanStartCallback() => ScanStart?.Invoke(this, EventArgs.Empty);
|
||||
void ScanEndCallback() => ScanEnd?.Invoke(this, new ScanEndEventArgs(scanError));
|
||||
void PageStartCallback() => PageStart?.Invoke(this, new PageStartEventArgs(++pageNumber));
|
||||
|
||||
void PageStartCallback()
|
||||
{
|
||||
pageNumber++;
|
||||
PageStart?.Invoke(this, new PageStartEventArgs(pageNumber));
|
||||
}
|
||||
void PageProgressCallback(double progress) =>
|
||||
PageProgress?.Invoke(this, new PageProgressEventArgs(pageNumber, progress));
|
||||
|
||||
void PageEndCallback(ProcessedImage image) => PageEnd?.Invoke(this, new PageEndEventArgs(pageNumber, image));
|
||||
|
||||
ScanStartCallback();
|
||||
|
@ -15,6 +15,11 @@ internal class EventThrottle<T>
|
||||
_eventCallback = eventCallback;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_hasLastValue = false;
|
||||
}
|
||||
|
||||
public void OnlyIfChanged(T value)
|
||||
{
|
||||
if (!_hasLastValue)
|
||||
|
Loading…
Reference in New Issue
Block a user