Escl: Fix port handling and server restarts

Ports are now static per SharedDevice where possible, falling back to random ports if an error occurs.
This commit is contained in:
Ben Olden-Cooligan 2023-12-02 22:57:30 -08:00
parent 062a056b6c
commit 29b3c57207
9 changed files with 130 additions and 42 deletions

View File

@ -11,16 +11,11 @@ public class EsclServer : IEsclServer
public void AddDevice(EsclDeviceConfig deviceConfig)
{
if (deviceConfig.Port == 0)
{
deviceConfig.Port = Port++;
}
var advertiser = new MdnsAdvertiser();
_devices[deviceConfig] = (advertiser, new CancellationTokenSource());
if (_started)
{
StartServer(deviceConfig);
Task.Run(() => advertiser.AdvertiseDevice(deviceConfig));
Task.Run(() => StartServerAndAdvertise(deviceConfig, advertiser));
}
}
@ -36,10 +31,7 @@ public class EsclServer : IEsclServer
_devices.Remove(deviceConfig);
}
// TODO: Better port handling
public int Port { get; set; } = 9898;
public void Start()
public Task Start()
{
if (_started)
{
@ -48,24 +40,36 @@ public class EsclServer : IEsclServer
_started = true;
_cts = new CancellationTokenSource();
var tasks = new List<Task>();
foreach (var device in _devices.Keys)
{
StartServer(device);
Task.Run(() => _devices[device].advertiser.AdvertiseDevice(device));
tasks.Add(Task.Run(() => StartServerAndAdvertise(device, _devices[device].advertiser)));
}
return Task.WhenAll(tasks);
}
private void StartServer(EsclDeviceConfig deviceConfig)
private async Task StartServerAndAdvertise(EsclDeviceConfig deviceConfig, MdnsAdvertiser advertiser)
{
// TODO: Auto free port?
var url = $"http://+:{deviceConfig.Port}/";
var cancelToken = CancellationTokenSource.CreateLinkedTokenSource(_cts!.Token, _devices[deviceConfig].cts.Token).Token;
// Try to run the server with the port specified in the EsclDeviceConfig first. If that fails, try random ports
// instead, and store the actually-used port back in EsclDeviceConfig so it can be advertised correctly.
await PortFinder.RunWithSpecifiedOrRandomPort(deviceConfig.Port, async port =>
{
await StartServer(deviceConfig, port, cancelToken);
deviceConfig.Port = port;
}, cancelToken);
advertiser.AdvertiseDevice(deviceConfig);
}
private async Task StartServer(EsclDeviceConfig deviceConfig, int port, CancellationToken cancelToken)
{
var url = $"http://+:{port}/";
var serverState = new EsclServerState();
var server = new WebServer(o => o
.WithMode(HttpListenerMode.EmbedIO)
.WithUrlPrefix(url))
.WithWebApi("/eSCL", m => m.WithController(() => new EsclApiController(deviceConfig, serverState)));
server.StateChanged += ServerOnStateChanged;
server.RunAsync(CancellationTokenSource.CreateLinkedTokenSource(_cts!.Token, _devices[deviceConfig].cts.Token).Token);
await server.StartAsync(cancelToken);
}
public void Stop()
@ -84,10 +88,6 @@ public class EsclServer : IEsclServer
_cts = null;
}
private void ServerOnStateChanged(object sender, WebServerStateChangedEventArgs e)
{
}
public void Dispose()
{
if (_started)

View File

@ -0,0 +1,43 @@
namespace NAPS2.Escl.Server;
internal static class PortFinder
{
private const int MAX_PORT_TRIES = 5;
private const int RANDOM_PORT_MIN = 10001;
private const int RANDOM_PORT_MAX = 19999;
public static async Task RunWithSpecifiedOrRandomPort(int defaultPort, Func<int, Task> portTaskFunc,
CancellationToken cancelToken)
{
int port = defaultPort;
int retries = 0;
var random = new Random();
if (port == 0)
{
port = RandomPort(random);
}
while (true)
{
try
{
await portTaskFunc(port);
break;
}
catch (Exception)
{
if (cancelToken.IsCancellationRequested)
{
break;
}
retries++;
port = RandomPort(random);
if (retries > MAX_PORT_TRIES)
{
throw;
}
}
}
}
private static int RandomPort(Random random) => random.Next(RANDOM_PORT_MIN, RANDOM_PORT_MAX + 1);
}

View File

@ -0,0 +1,30 @@
using EmbedIO;
namespace NAPS2.Escl.Server;
internal static class WebServerExtensions
{
public static async Task StartAsync(this WebServer server, CancellationToken cancelToken = default)
{
var startedTcs = new TaskCompletionSource<bool>();
server.StateChanged += (_, args) =>
{
if (args.NewState == WebServerState.Listening)
{
startedTcs.TrySetResult(true);
}
};
_ = server.RunAsync(cancelToken).ContinueWith(t =>
{
if (t.IsFaulted)
{
startedTcs.TrySetException(t.Exception!);
}
else
{
startedTcs.TrySetCanceled();
}
});
await startedTcs.Task;
}
}

View File

@ -18,11 +18,12 @@ public class AdvertiseTests
{
Version = "2.6",
MakeAndModel = "HP Blah",
SerialNumber = "123abc"
SerialNumber = "123abc",
Uuid = Guid.NewGuid().ToString("D")
},
CreateJob = _ => job
});
server.Start();
await server.Start();
if (Debugger.IsAttached)
{
for (int i = 0; i < 100; i++)

View File

@ -11,32 +11,28 @@ public class ClientServerTests
[Fact]
public async Task ClientServer()
{
// TODO: Any better way to prevent port collisions when running tests?
#if NET6_0_OR_GREATER
int port = 9802;
#else
int port = 9801;
#endif
var job = Substitute.For<IEsclScanJob>();
using var server = new EsclServer { Port = port };
server.AddDevice(new EsclDeviceConfig
using var server = new EsclServer();
var deviceConfig = new EsclDeviceConfig
{
Capabilities = new EsclCapabilities
{
Version = "2.0",
MakeAndModel = "HP Blah",
SerialNumber = "123abc"
SerialNumber = "123abc",
Uuid = Guid.NewGuid().ToString("D")
},
CreateJob = _ => job
});
server.Start();
};
server.AddDevice(deviceConfig);
await server.Start();
var client = new EsclClient(new EsclService
{
IpV4 = IPAddress.Loopback,
IpV6 = IPAddress.IPv6Loopback,
Host = IPAddress.IPv6Loopback.ToString(),
RemoteEndpoint = IPAddress.IPv6Loopback,
Port = 9801,
Port = deviceConfig.Port,
RootUrl = "eSCL",
Tls = false
});

View File

@ -4,7 +4,6 @@ public interface IEsclServer : IDisposable
{
void AddDevice(EsclDeviceConfig deviceConfig);
void RemoveDevice(EsclDeviceConfig deviceConfig);
int Port { get; set; }
void Start();
Task Start();
void Stop();
}

View File

@ -10,6 +10,8 @@ namespace NAPS2.EtoForms.Ui;
public class SharedDeviceForm : EtoDialogBase
{
private const int BASE_PORT = 9801;
private readonly IScanPerformer _scanPerformer;
private readonly ErrorOutput _errorOutput;
@ -114,6 +116,8 @@ public class SharedDeviceForm : EtoDialogBase
}
}
private int Port { get; set; }
private Driver DeviceDriver
{
get => _twainDriver.Checked ? Driver.Twain
@ -151,6 +155,7 @@ public class SharedDeviceForm : EtoDialogBase
_displayName.Text = SharedDevice?.Name ?? "";
CurrentDevice ??= SharedDevice?.Device;
Port = SharedDevice?.Port ?? 0;
DeviceDriver = SharedDevice?.Device.Driver ?? ScanOptionsValidator.SystemDefaultDriver;
@ -198,10 +203,22 @@ public class SharedDeviceForm : EtoDialogBase
SharedDevice = new SharedDevice
{
Name = _displayName.Text,
Device = CurrentDevice!
Device = CurrentDevice!,
Port = Port == 0 ? NextPort() : Port
};
}
private int NextPort()
{
var devices = Config.Get(c => c.SharedDevices);
int port = BASE_PORT;
while (devices.Any(x => x.Port == port))
{
port++;
}
return port;
}
private void Ok_Click(object? sender, EventArgs e)
{
if (_displayName.Text == "")

View File

@ -25,8 +25,8 @@ public class ScanServer : IDisposable
public void SetDefaultIcon(byte[] iconPng) => _defaultIconPng = iconPng;
public void RegisterDevice(ScanDevice device, string? displayName = null) =>
RegisterDevice(new SharedDevice { Device = device, Name = displayName ?? device.Name });
public void RegisterDevice(ScanDevice device, string? displayName = null, int port = 0) =>
RegisterDevice(new SharedDevice { Device = device, Name = displayName ?? device.Name, Port = port });
public void RegisterDevice(SharedDevice sharedDevice)
{
@ -35,8 +35,8 @@ public class ScanServer : IDisposable
_esclServer.AddDevice(esclDeviceConfig);
}
public void UnregisterDevice(ScanDevice device, string? displayName = null) =>
UnregisterDevice(new SharedDevice { Device = device, Name = displayName ?? device.Name });
public void UnregisterDevice(ScanDevice device, string? displayName = null, int port = 0) =>
UnregisterDevice(new SharedDevice { Device = device, Name = displayName ?? device.Name, Port = port });
public void UnregisterDevice(SharedDevice sharedDevice)
{
@ -49,6 +49,7 @@ public class ScanServer : IDisposable
{
return new EsclDeviceConfig
{
Port = device.Port,
Capabilities = new EsclCapabilities
{
MakeAndModel = device.Name,

View File

@ -8,6 +8,7 @@ public record SharedDevice
{
public required string Name { get; init; }
public required ScanDevice Device { get; init; }
public required int Port { get; init; }
public string Uuid
{