Escl: Job cleanup and minor fixes

This commit is contained in:
Ben Olden-Cooligan 2023-12-12 20:55:09 -08:00
parent 0cb36e55f1
commit 9092870ee7
20 changed files with 285 additions and 86 deletions

View File

@ -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
{

View File

@ -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()

View File

@ -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;
}
}
}
}
}
}

View File

@ -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()
{
}
}

View File

@ -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();
}
}
}

View File

@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="EmbedIO" Version="3.5.2" />
<PackageReference Include="Nullable" Version="1.3.1" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>

View File

@ -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;
}

View File

@ -1,6 +1,6 @@
namespace NAPS2.Escl.Server;
public interface IEsclScanJob
public interface IEsclScanJob : IDisposable
{
string ContentType { get; }
void Cancel();

View File

@ -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; }
}

View File

@ -4,5 +4,6 @@ public enum StatusTransition
{
CancelJob,
AbortJob,
DeviceIdle
ScanComplete,
PageComplete
}

View File

@ -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")

View File

@ -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()

View File

@ -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()

View File

@ -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)

View File

@ -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));
}
}

View File

@ -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();
}
}
}

View File

@ -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();
}

View File

@ -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 =>
{

View File

@ -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();

View File

@ -15,6 +15,11 @@ internal class EventThrottle<T>
_eventCallback = eventCallback;
}
public void Reset()
{
_hasLastValue = false;
}
public void OnlyIfChanged(T value)
{
if (!_hasLastValue)